Skip to content

factory.job_create: issue choice, labels, starting budget, retry key, split_from; factory.job_context_set - #2617

Merged
AbirAbbas merged 11 commits into
devfrom
feat/factory-create-extras
Nov 13, 2026
Merged

AbirAbbas merged 11 commits into
devfrom
feat/factory-create-extras

Conversation

@AbirAbbas

@AbirAbbas AbirAbbas commented Nov 12, 2026 •

Copy link
Copy Markdown
Collaborator

Merge together with #2619.

What this adds

factory.job_create can say more about the job it makes, and a job can be given reference material before its first run.

  • factory.job_create takes optional open_issue (opens one GitHub issue with the title and text, through POST /v1/issues), group and milestone (opaque labels kept on the job and returned on its reads), budget (one of the standard steps, the job's cap from creation), key (sent as Idempotency-Key, and only to a Factory whose GET /v1/features lists keyed_create; there a resend with the same key within 24 hours does not make a second job or issue) and split_from (the job this one was split from: a job of the same Factory the caller can see; the new job's reads return it and its activity records split from #N; the parent is unchanged).
  • factory.job_context_set {factory, job, expect, context} replaces a job's context attachments while it has no run: up to 20 {title, markdown, source?} items, 100,000 characters in all, outside the description limit. The plan step that runs reads them (the first conversation step when there is no plan step). Job reads list them by title and source only; they never go to GitHub.
  • Announced as: capabilities create_issue, create_group, create_budget, create_split_from and job_context. create_key is true only in an answer to factory.capabilities {factory} (a new optional parameter) for a Factory that lists keyed_create, and false without factory. Hello feature factory.job_context. A Factory older than a field or route answers conflict with detail.reason: "factory_too_old" and nothing changes.
  • A create key goes only to a Factory that says keyed_create. A Factory older than Factory: a keyed create that wrote nothing runs again, announced as keyed_create; a rate-limited poll says so #2619 keeps every keyed answer for 24 hours, a refusal or a 5xx included, so a fixed per-draft key would replay a transient failure or a fixed refusal for a day. The engine asks GET /v1/features on every create that carries a key. To a Factory without the word that create answers conflict with detail.reason: "factory_too_old", and no create is sent. Creates without a key are unchanged. Two limits remain: a resend under a new bearer or session cookie is not matched, and a Factory that stops after committing a create but before saving its answer runs the resend again.
  • The chat manual's Factory page describes making a job with an issue, labels, a budget, a retry key and a split parent.

Who calls it

The desktop app's Work place (codeaf-desk crates/core/src/engine/factory_actions.rs, factory_create_extras.rs, factory_job_context.rs) already sends these behind the capability flags: a workstream's Launch sends group, milestone, budget and context, and its failed-job card splits a job. Its retry key goes out only when create_key is true. The desk asks factory.capabilities without naming a Factory, so it gets create_key: false and sends no key until it passes factory. No engine on dev announces them today.

Where it comes from

This carries #2415 and #2429, which merged only into workstreams/main. That branch's integration PR (#2306) is closed and will not merge. Adapted to dev as it is now:

Tests

CI on this head (f0996a4): check, light gate, touched codeaf, touched rest, touched session, touched tui3, touched packages and windows native tests pass.

Locally (macOS), on this head (f0996a4):

  • In cmd/codeaf, by name with -p 2: TestAppFactoryCreateCapabilities, TestAppFactoryCreateKeyGoesOnlyToAFactoryThatSaysSo, TestAppFactoryCreateKeyReplaysJobNumber, TestAppFactoryCreateRoutesBodyAndKey and their neighbours (every test that reads factory.capabilities, all create-extras, job-context and split_from tests, and the four job-action real-server tests): 40 of 40 pass. On the code before this round, the first three fail: create_key was true with no Factory named, and a key reached a Factory that does not list keyed_create and made a job.
  • go test -count=1 -p 2 ./internal/appwire/ ./internal/factory/api/ ./internal/factory/serve/: all ok.
  • make test-packed-manual manual-gates: exit 0. make changelog-check: 795 entries, all well formed. make test-quick: exit 0.
  • Merged with Factory: a keyed create that wrote nothing runs again, announced as keyed_create; a rate-limited poll says so #2619 (c937532) on dev 6616e85, in a throwaway worktree: go build ./... ok; internal/factory/serve, api, run, store and internal/appwire ok; 41 named cmd/codeaf tests pass. One of them was a throwaway end-to-end test: the real merged Factory gets create_key and the key, a pre-write 501 under the key runs again, and then the same key replays one job. The same server answering features as an older Factory does gets create_key: false, a keyed create answers factory_too_old, and the older Factory never sees the key. The same test on this branch alone fails as intended, because no Factory lists the word. Manual gates, make changelog-check (803 entries) and make test-quick exit 0.

Locally (macOS), on the earlier head d773433, make pr-ready did not pass as one command. The PR was marked ready on the strength of CI and the comparison with dev below:

  • The light gate passed (build, vet, formatting, packed manual, change entries, manual gates, laws).
  • The touched packages other than cmd/codeaf passed fresh:
    ok  	github.com/Agent-Field/codeaf/internal/appwire	3.397s
    ok  	github.com/Agent-Field/codeaf/internal/factory	8.924s
    ok  	github.com/Agent-Field/codeaf/internal/factory/api	0.187s
    ok  	github.com/Agent-Field/codeaf/internal/factory/pipeline	2.786s
    ok  	github.com/Agent-Field/codeaf/internal/factory/record	1.344s
    ok  	github.com/Agent-Field/codeaf/internal/factory/run	178.812s
    ok  	github.com/Agent-Field/codeaf/internal/factory/serve	115.930s
    ok  	github.com/Agent-Field/codeaf/internal/factory/store	8.528s
    
  • The cmd/codeaf leg was refused inside pr-ready by the one-heavy-suite lock (other sessions were running suites on the machine), so it was run by itself once the lock was free (make test PKGS=./cmd/codeaf TEST_FLAGS='-count=1 -p 2' SHARDS=8). 12 tests failed. None is attributable to this change:
    • 9 fail the same way alone on a clean origin/dev (ffeeb78) on the same Mac: TestAppFactoryPlanFilesAnswer, TestFactoryCommandStepRetryAfterOutOfAttempts, TestFactoryMeasuredOnlyPublication, TestFactoryRetryActionsDoNotReportStopped, TestFactoryStepRetryWaitsForPreviousLeaseCleanup, TestFactoryWriteUsesJobBaseAcrossAttempts, TestMemoryPauseResumeAfterTrailingProgress, TestMemoryPauseRetainsPendingForResume, TestMemoryPausedJobLifecycle (for example write is not green: exit 127: the Factory's command steps do not find their shell on this Mac).
    • TestHostedTakeBackReachesAcquireWithoutSharedUploadsOrReleaseDelay and TestPoolJudgeHookAppendsOutboxRowsOnlyWhenTheModeSends passed when run again.
    • TestProcessCloseJoinsTheUsageWriter passes alone (3 of 3) and fails after those two in one process (live usage writers = 2, want 0); it fails the same way in that order on clean origin/dev.

The change's own tests pass locally: the TestAppFactoryCreate* and TestAppFactoryJobContext* tests in cmd/codeaf (capabilities, routes, retry key, older-Factory refusals, split parent), the create-extras, job-context and split tests in internal/appwire, internal/factory/serve, internal/factory/run, internal/factory/api, internal/factory/pipeline and internal/factory/store, and make test-packed-manual manual-gates for the manual page.

The engine answers create_issue, create_group, create_budget, create_split_from and job_context as true from factory.capabilities {} and announces factory.job_context in hello. create_key is true only for a named Factory that lists keyed_create (TestAppFactoryCreateCapabilities; on dev + #2617 + #2619, a real Factory gets the key and an older one does not).

Changes after review

Round 1.

  • A 404 from the current Factory's POST /v1/issues was always reported as factory_too_old, including its JSON 404 repository not found for a repository the person cannot see. Now only a 405, or a 404 whose body is not JSON or says factory endpoint not found, means a Factory too old. Any other 404 stays not_found with the Factory's words. The check is one helper, missingRouteBody, shared with the job-context route (c5dd897).
  • An explicit $5 starting budget could be lowered after the clone. The settle rule (settledCap) took any $5 cap for the default a start used before the rules were read, and lowered it to a smaller rules default. A create's budget is now marked as chosen (Item.CapChosen, carried to the runner in the job desk section and kept by Run as new), and the settle skips a chosen cap. Old jobs and creates without a budget behave as on dev (520e293).
  • The docs said context markdown must be non-empty. They now say it must contain more than whitespace, which is what the code refuses, and the Factory's refusal test has a blank-markdown row (61242ad).
  • The retry-key docs claimed a replay rule that Factory: a keyed create that wrote nothing runs again, announced as keyed_create; a rate-limited poll says so #2619 changes. They were cut back to what holds with or without Factory: a keyed create that wrote nothing runs again, announced as keyed_create; a rate-limited poll says so #2619, and the change entry said the two must merge together (61242ad; 3879b8c brought the manual section back under its size).

Pass 2.

  • Review found that a fixed per-draft key is unsafe against a Factory older than Factory: a keyed create that wrote nothing runs again, announced as keyed_create; a rate-limited poll says so #2619. That Factory keeps every keyed answer for 24 hours, so a transient failure or a fixed refusal came back for a day, even the factory_too_old refusal after the Factory was updated. The engine now sends a key only to a Factory that announces Factory: a keyed create that wrote nothing runs again, announced as keyed_create; a rate-limited poll says so #2619's rule (f6e2659):
    • factory.capabilities takes an optional {factory} (appwire.FactoryCapabilitiesParams). The dispatcher owns create_key: it is true only when a Factory is named, the engine implements the new optional appwire.FactoryCreateKeys, and that Factory's GET /v1/features lists keyed_create. With no Factory named, or a Factory that does not list it or cannot be asked, it is false. appBackend no longer claims it on its own, and the Factory interface and its callers are unchanged.
    • createJob reads GET /v1/features again on every create that carries a key. Without the word it answers conflict with detail.reason: "factory_too_old" and sends no create. A 404 on /v1/features means a Factory older than the route, so the same refusal. Any other failure of that read returns the read's own error and sends nothing. An unknown Factory stays not_found. A create without a key is unchanged and makes no extra read.
    • It refuses rather than dropping the key, because a caller that sent a key expects a resend after an uncertain answer to be safe. Dropping it could make a second job.
    • internal/factory/api/keyed_create.go, which defines the word, is the same file byte for byte on Factory: a keyed create that wrote nothing runs again, announced as keyed_create; a rate-limited poll says so #2619.
  • The docs now state the two replay limits: a resend under a new bearer or session cookie is not matched, and a Factory that stops between committing a create and saving its answer runs the resend again (d59d286).
  • The manual again names a 404 whose body is not JSON as a Factory too old; round 1's size trim had dropped it (f0996a4).
  • A desk follow-up, outside this PR: the desk on main calls factory.capabilities without a Factory, so it now gets create_key: false and sends no key. Its keyed retry comes back once it passes factory for the workstream's Factory.

🤖 Generated with Claude Code

AbirAbbas and others added 4 commits November 12, 2026 12:05
…y key; factory.job_context_set

Carried from #2415 on workstreams/main (closed integration PR #2306), adapted to
dev: the Factory capability struct, wire Job and Item keep dev's go-ahead
fields; the new job's group, milestone and context sit beside them. The job
outcome's group is left out here because job outcomes are a separate change.

Co-Authored-By: Claude Mythos 5.2 <[email protected]>
Carried from #2429 on workstreams/main (closed integration PR #2306). The
parent check reads the saved job, as the follow-up there made it, so checking
a split asks GitHub nothing about the parent's author and never writes it.

Co-Authored-By: Claude Mythos 5.2 <[email protected]>
factory.repo_issues names the same reason; both changes add this one file
unchanged, so they merge in either order.

Co-Authored-By: Claude Mythos 5.2 <[email protected]>
Co-Authored-By: Claude Mythos 5.2 <[email protected]>
AbirAbbas and others added 7 commits November 12, 2026 18:33
…_found

Every 404 or 405 on POST /v1/issues was reported as factory_too_old, even
the current Factory's own JSON 404 "repository not found" from newJob's
visibility check. A person was told to update a Factory that was current,
and the real cause, a repository their GitHub cannot see, was lost.

A 404 there is now too old only when its body is the mux's
"factory endpoint not found" or not the Factory's JSON, the same body test
the context route already used (now one helper, missingRouteBody). A 405
stays too old: every Factory that serves /v1/issues takes POST there (GET
and POST landed together in #2561). Any other 404 keeps the not_found
mapping and the Factory's words. The "issue 404" case of
TestAppFactoryCreateOlderFactoryRefusals now sends the body an older
Factory really sends; its JSON "not found" was the broad match this fixes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The create text promised that the Factory replays the first response for
24 hours, so a same-key resend after an uncertain answer is safe. #2619,
which merges with this, changes which keyed answers are replayed: one given
before the create wrote anything runs again. PROTOCOL.md, the chat manual
and the change entry now state only what is true on either branch: within
24 hours a same-key resend never makes a second job or issue, a different
key makes a separate job, and a still-uncertain resend means read the
queue. The change entry says this must merge with #2619, which carries the
replay rule itself.

Context attachments: the docs said markdown must be non-empty, but the
engine and the Factory both refuse whitespace alone ("context markdown
must be non-empty"). The docs now say so, and the Factory's refusal table
pins the blank case the engine's table already pinned.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…he clone

settledCap lowers a job's budget to its repository file's smaller
default_budget when the start had to take DefaultJobBudget ($5) because the
repository was not here yet. It told the two apart by amount alone, so a
person who created a job with budget 5 had it lowered to the file's default
(say $2) once the runner had cloned, although the docs promise the create's
budget is kept on an immediate or later start.

The create's stamp now marks its budget as chosen (Item.CapChosen), Run as
new keeps that mark with the amount, the worker wire carries it in the job's
desk section (cap_chosen), and settledCap leaves a chosen budget alone. A
budget a start took is still lowered as before, and jobs made without a
budget are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The two corrections to "Creating a job with an issue, group, milestone,
budget and retry key" (the 404 that stays not_found and the keyed resend
wording) took the section past the manual's 2,000-character guide. Same
claims, fewer words.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…ed_create

A Factory older than #2619 keeps every keyed answer for 24 hours, a
refusal or a 5xx included, so the desk's fixed per-draft key would
replay a transient failure or a fixed refusal for a day and the draft
could not launch. The engine now asks the Factory's GET /v1/features on
every create with a key and sends the key only when it lists
keyed_create; to any other Factory a keyed create answers conflict
factory_too_old and no create is sent. A create without a key is
unchanged and asks nothing.

factory.capabilities takes an optional factory: create_key is true only
in an answer naming a Factory that lists the word, and false without
one, so an app that reads capabilities once sends no key. On its own
this branch never sees the word and never sends a key. The word's
constant sits in its own file, identical on #2619.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
A resend with the same key was said never to make a second job or
issue. The Factory saves its answer only after the create commits, so a
Factory that stops in between runs the resend again, and it matches a
resend by the exact Authorization and Cookie headers, so one sent under
a new bearer or session cookie runs as a new create. PROTOCOL.md, the
manual and the change entry now say both.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The create-extras section named two signs of a Factory without
/v1/issues, a 405 and a 404 saying "factory endpoint not found", and
left out a 404 whose body is not the Factory's JSON, which the engine
(missingRouteBody) and PROTOCOL.md also count as too old.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
AbirAbbas added a commit that referenced this pull request Nov 13, 2026
The Factory now says it applies the keyed create rule: a keyed POST
/v1/jobs or /v1/issues answered before its first write is not kept, so
the same key runs again. An older Factory keeps every keyed answer for a
day, refusals and server errors included, so an engine must be able to
tell the two apart before it sends a create key. The word is additive
and lives in the request path's rule, so a host without a store lists it
too. The constant sits in its own file, identical on #2617, so the two
branches merge without a conflict.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
AbirAbbas added a commit that referenced this pull request Nov 13, 2026
…eyed_create; a rate-limited poll says so (#2619)

* Factory: a keyed request is never answered from a cached 5xx or 429; a rate-limited poll says so

Carried from #2439 and #2460 on workstreams/main (closed integration PR #2306).
The idempotency cache no longer keeps or replays a try-again answer (5xx, 408,
429), including rows an older build stored, so a keyed retry after a transient
failure can succeed. A source poll that GitHub answers 429, or 403 with a
retry time, reads "rate limited" instead of "not reachable".

Co-Authored-By: Claude Mythos 5.2 <[email protected]>

* changes: entry for this pull request

Co-Authored-By: Claude Mythos 5.2 <[email protected]>

* Factory: a keyed create is replayed unless it wrote nothing

The keyed-answer rule this branch carried from #2439 stopped keeping every
5xx, 408 and 429. A job create that failed after it had made its job, or
after its GitHub issue request had gone out, then ran again on the same-key
resend the engine calls safe after an uncertain answer, and made a second
job or a second GitHub issue.

The rule is now: a keyed answer is replayed unless its handler said it
committed nothing. The handler says so with the existing phase marker
(X-Codeaf-Phase: pre-mutation). POST /v1/jobs and /v1/issues now start
marked. newJob clears the marker immediately before seam.New/NewJob, and
githubJobJSON clears it once GitHub may have acted: the request got a
connection, or GitHub answered anything but its own 4xx refusal. An answer
still marked (a refusal, the visibility check, a sign-in or read failure, a
GitHub refusal, or GitHub unreachable before the request was sent) is not
kept, so a resend runs again once the cause passes. Every answer after a
write is replayed, a 5xx included. The capture writer now receives the
marker from the real writer, so clearPhase reaches it, and a replay never
carries it. Worker and runner routes keep dev's rule.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Factory: GET /v1/features lists keyed_create

The Factory now says it applies the keyed create rule: a keyed POST
/v1/jobs or /v1/issues answered before its first write is not kept, so
the same key runs again. An older Factory keeps every keyed answer for a
day, refusals and server errors included, so an engine must be able to
tell the two apart before it sends a create key. The word is additive
and lives in the request path's rule, so a host without a store lists it
too. The constant sits in its own file, identical on #2617, so the two
branches merge without a conflict.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* serve tests: kept keyed answers go through the route's marker; NewJob's clear is proven

TestKeyedSuccessAndRefusalsStillReplay called idempotent without the
marker ServeHTTP sets, so it claimed a /v1/jobs 422 replays for a day,
which the real route no longer does. It becomes TestAKeptKeyedAnswerReplaysForADay:
a create's success and an unmarked verb's refusal, both through the
marker. TestAKeyedCreateThatWroteNothingRunsAgain gains a real /v1/jobs
refusal the person fixed. A create through the seam's NewJob whose start
failed must be replayed, and an unkeyed one made through NewJob carries
no marker: both fail if the clearPhase before seam.NewJob is deleted.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs: a keyed create's replay has two limits

The docs said a resend with the same key never makes a second job or
issue. The replay row is saved only after the create commits, so a
Factory that stops in between runs the resend again, and the row is
keyed by the exact Authorization and Cookie headers, so a resend under
a new bearer or session cookie is a new create. PROTOCOL.md, the manual
and the change entry now say both.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Mythos 5.2 <[email protected]>
@AbirAbbas
AbirAbbas merged commit 2ec80c8 into dev Nov 13, 2026
10 checks passed
@AbirAbbas
AbirAbbas deleted the feat/factory-create-extras branch November 13, 2026 02:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant