Repository navigation
Factory job outcomes: a durable cursor read and a pushed factory.event - #2616
Merged
Merged
Conversation
Carried from #2416 on workstreams/main (closed integration PR #2306), with the follow-up that reads a job's visibility from its saved copy. On dev a job a person stopped waits in Backlog and is not needs-you, so a stop records no outcome; the two stop cases of the stored-outcome test say so. Co-Authored-By: Claude Mythos 5.2 <[email protected]>
So this change and the other Factory additions carried from workstreams/main merge past each other. Co-Authored-By: Claude Mythos 5.2 <[email protected]>
Co-Authored-By: Claude Mythos 5.2 <[email protected]>
A job waiting at the plan gate recorded another identical approve_plan outcome on every write while it waited: each runner report, priority and budget change pushed one more factory.event (six for one approval in the hand test). Store.update compared the new job against live.OutcomeBasis, a copy of only some fields. The copy dropped each step's kind and round, so the waiting gate read as a hold before a work step (approve_step) while the job answered approve_plan, and it dropped the paused-decision mark, so a paused question's reason differed too; the need looked changed on every write. Store.update now hands its callback a decoded copy of the job, so the job it read is intact after the change. Outcomes compare that whole prior job, as Save already did, and the partial copy is gone. Co-Authored-By: Claude Opus 5.5 <[email protected]>
factory.job_stop at the plan gate recorded and pushed a failed outcome (code stopped, step approve), which the desktop app takes as a failed job and wakes its agent for. Every road that ends a run on a person's word cuts the step it finds under way and writes the stop on that step and on the job as a failure coded stopped: Stop at a gate or in a running step (the service's Stop door and the local runner's), a step's own Stop, a no to the stop after a step, Close, Reject, the reset before a reassignment and a cancel out of attempts. live.Outcomes recorded every new failure as failed, so each of these did, and Close and Reject recorded a failed beside their finished. The store test that says a stop is quiet only flipped the person-stop mark and never wrote that failure. A failure coded stopped, or any failure on a job a person stopped (it waits in Backlog), is no failed outcome now, as the history record already skips it. Tests drive the real stop writes: the store with the service door's write, every lease door, and the local loop's Stop at a gate, Stop mid-step and the stop after a step. A step's own failure and a lost runner still record failed once. Co-Authored-By: Claude Opus 5.5 <[email protected]>
A check-only job whose publish GitHub refused recorded publish_failed twice, at 17:38:54.849 and 17:38:55.360, two occurrences for the desktop app. On a Factory service the runner reports the failure while the service's copy of the job still runs the publish, and the runner's done then lands the job. factory.NeedsSince read the landed run's clock only for a landed job, so the same wait answered the current step's clock at the report and the run's end at the done; the outcome saw its wait start move and recorded it again, and factory.job's needs_you.since moved with it. A failed publish now waits from its own clock, the time the publish failed, whatever state the job's copy is in. factory.job answers that time too, so a publish retried and refused again is a new wait rather than the first refusal's. The change entry says so, and names the stop, Close and Reject rule the earlier fix enforces. Co-Authored-By: Claude Opus 5.5 <[email protected]>
The stored-outcome table had merged, answered, closed and dropped but no job closed locally, the finish a person's Close writes; it records one finished outcome with how closed_locally at the job's done time. Co-Authored-By: Claude Opus 5.5 <[email protected]>
A worker parked at its budget sends the cap question again, with no clock, on every later report that carries thread messages, usage or acknowledgements. reportQuestion stamped each one with the report's arrival, so needs_you.since moved and every report recorded another budget needs_you the desktop app read as a new wait. The same wait sent again (the ask still stands, same kind and step, same words or a cap) now keeps the clock it was first asked at and adds no second ask line. Co-Authored-By: Claude Opus 5.5 <[email protected]>
While a Factory's live record had a gap an older writer left (outcomes_through != next), every job-events read answered reset at once, even with wait=15, and the engine's follower, reset to the cursor it asked, read again with no delay: back-to-back requests, each settling every job, until something committed. The Factory read that finds the gap now closes it (live.HealOutcomes, the same move the next commit makes), so a reader resets once to a cursor past it. The follower waits after a reset that left its cursor where it was, 2 seconds doubling to a minute, for Factories without the repair. Co-Authored-By: Claude Opus 5.5 <[email protected]>
A signed-in caller's job-events cursor moved past every outcome it was not shown, including one whose repository check had only failed for a moment (GitHub 5xx or a transport error, not cached): the caller saved the cursor and lost the outcome for good. repoAllowed now says whether GitHub answered (repoAccessDecided names the keys it did not). The job-events page ends before an undecided outcome with more: true, and a read that would start at one answers 503 try again, so the cursor stays and the caller reads it later. Co-Authored-By: Claude Opus 5.5 <[email protected]>
personStop suppressed every failure coded stopped, but only a job StoppedByPerson waits in Backlog. Roads that end a run without the person-stop mark left factory.job showing a failed job in Needs you (failure stopped, Stopped by you) while events recorded nothing: the reset before a change of assignee (PersonStopped=&false), an unheld cancel out of attempts, and the loop's own stops on a cancel, a no to the budget or a send back with nowhere to go, once a Start had cleared an earlier stop. live.Outcomes now records failed by JobState: nothing in Backlog, always in Needs you, and on a done job anything but a stop (a Close or Reject is its finished). Each person's-word road now marks the stop as the person's (AnswerUnheld cancel, floorLoop.stop when the loop's stop is a person's), and a reset hands the job on in Backlog: it drops the job-level stop it wrote, the cut step keeps it. The served copy of a loop-made stop is the next commit; the change entry, PROTOCOL and manual text here already describe both. Co-Authored-By: Claude Opus 5.5 <[email protected]>
On a Factory service a cancel out of attempts or a no to the budget reaches the runner that holds the job; its loop ends the run on its own copy, marks the stop there, and reports the cut step. The service's copy, which factory.job reads and outcomes are recorded from, learned of the stop only through StampFailure and never of the mark, so a job a Start had cleared read as a failed job in Needs you while the runner's copy waited in Backlog. reportStep now takes a reported step failure coded stopped as the person's stop: only a person's word writes that code on a runner. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Events and a full factory.job read gave different occurrence keys for
a person's stop: factory.job still carries failure {code: stopped, at}
on a Backlog job (and on a closed or rejected one), and a client keying
failed:<at> from every failure woke for a stop events never record. A
failed job's wait also keyed needs_you:<kind>:<since> from the full
read, which events never record beside failed.
factory.job already says which is which (state backlog, done or
needs_you; desk.person_stopped), so nothing is added to the wire. The
outcomes section now states the rule a full read follows; the desktop
app's workstream_watch_occurrences needs the matching filter.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
…rrences Each stop road in the agreement table now also checks that the occurrence keys a full factory.job read forms, by the rule PROTOCOL.md gives (failed only in needs_you, or on a done job when it is no stop; a wait only on a job with no failure), are all keys the outcomes recorded. Keyed the way the desktop app keys a full read today (every failure, every wait), a person's stop, a Close, a Reject and a runner's stop each key a failed occurrence no outcome has, and a failed job a needs_you:failed one. Co-Authored-By: Claude Opus 5.5 <[email protected]>
With no runner holding the job, a send back with no earlier step is
refused ("there is no previous work step to send back") and a no to the
budget is kept as the answer for the next round; only a cancel stops
it there. The manual and the change entry now say so.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
… it began (item 1) A start held for approval, a take-over, an AgentField run's approval, a Stop undone at a gate, a failed merge and a pull request's review or conflict wait on a person with no failure, ask or step clock. NeedsSince fell back to Changed, which every stamped write moves, so a rename, a priority, a budget or a note each recorded another needs_you outcome, which the desktop app keys as a new occurrence, and moved factory.job's needs_you.since with it. Every store write now keeps Item.NeedsAt (factory.NoteWait): stamped by the write that began a wait, kept while the job waits with the same need, question kind and question, cleared when it waits on nobody. NeedsSince reads it before Changed. A waiting job written before the clock was kept goes on from the clock it showed, so its next write records nothing. NeedsAt is store bookkeeping: it is outside the action revision and the wire carries it as needs_you.since. Co-Authored-By: Claude Opus 5.5 <[email protected]>
A change of assignee or an unassign of a job waiting at a gate or out of attempts left it in Backlog with the cut run's ask standing (Asking With you), so factory.job answered backlog with waiting_since, which the desktop app's full read keys as a wait on a person. The lease reset that cuts the run now takes the ask down with it, as a person's stop does, and the unassign write takes down an ask a failed run left (no reset cuts a run that already ended). The stop-road table now checks that a job in any state but needs_you shows no waiting_since and no needs_you, and its full-read keys model the desktop app's waiting_since fallback. Co-Authored-By: Claude Opus 5.5 <[email protected]>
The reset that hands a job on stopped writing its stop on the job as a failure, so RunEnded and CanRunAsNew turned false and Run as new went away for the new person (the run_as_new door refused it), for a run that had ended all the same. CanRunAsNew now also reads a new job whose run ended with its current step cut by a stop. Co-Authored-By: Claude Opus 5.5 <[email protected]>
The change entry, PROTOCOL's job outcomes and run-as-new sections and the manual's hand-on section say that needs_you.since is when the wait began, that a change of assignee or an unassign takes down the cut run's question and offers Run as new, and how a full read keys a wait. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Brings in #2613, #2612, #2614, #2618 and #2615. #2618 changes the visibility check this branch's job-events cursor reads: a spent GitHub rate limit returns GitHub's retry time instead of a cached denial, and is remembered per token until then. The two meet in repoAllowed, which this branch made answer (allow, known). A remembered limit answers false, false: a limit says nothing about the repository, so the job-events page ends before it as before any undecided one, as a fresh limit already does (askRepo's cache=false). Co-Authored-By: Claude Opus 5.5 <[email protected]>
…(item 3) A rate-limited visibility check (403 with no requests remaining, or a 429 secondary limit) was decided as hidden: the job-events cursor passed the outcome, the denial was kept for the visibility TTL, and a signed-in caller whose token was rate-limited lost the outcome for good. Since the merge of origin/dev (#2618) a spent limit returns GitHub's retry time and is never cached as a denial, and the merge's resolution answers a remembered limit as not known. The page now ends before such an outcome, a read that would start at it answers try again (503), and once the limit resets the outcome is shown. TestJobEventsWaitOutAGitHubRateLimit pins both a primary and a secondary limit; the GitHub fake can answer a limit with its headers. PROTOCOL and the change entry name the rate limit. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this adds
The Factory keeps a durable record of job outcomes and serves it from a cursor, so an app that was not connected when a job merged, finished, failed or started needing a person can still learn of it.
merged,finished(with how),failed(code, step) orneeds_you(need, reason), with the job's own time for that fact. The newest 2,000 are kept injob-outcomes.jsonbeside the live record and served byGET /v1/job-events?after=&limit=&wait=for the jobs the caller may see. The live record and/v1/eventsare unchanged.factory.events_since {factory, after?, limit?, follow?}answers{factory, supported, events, cursor, reset, more}; withfollowthe engine pushes later outcomes asfactory.event. Hello featurefactory.job_events, capabilityjob_events. A Factory older than the route answerssupported: false.Who calls it
The desktop app's Work place (codeaf-desk
crates/core/src/engine/factory_events.rs) already calls this behind thefactory.job_eventshello feature, to wake a workstream's agent when its jobs change. No engine ondevannounces the feature today, so that path is switched off.Where it comes from
This carries #2416, which merged only into
workstreams/main. That branch's integration PR (#2306) is closed and will not merge, so the change never reacheddev. Adapted todevas it is now:deva job a person stopped waits in Backlog and is no longerneeds_you(factory.JobState), so stopping a job records nothing. The two "stop enters needs-you" cases ofTestStoredOutcomesFromInPlaceNestedUpdatesnow assert that.workstreams/main(0190d57) is folded in: the cursor checks a job's repository withStore.GetSaved, so reading it looks up no author access on GitHub and writes no job.TestJobEventsVisibilityUsesSavedAuthorAccesscovers it and fails (3 lookups) with the hydrating read.groupfrom the job's group. A job has noGroupfield ondevuntil the create-extras PR (factory.job_create: issue choice, labels, starting budget, retry key, split_from; factory.job_context_set #2617) lands; the wire field stays and is empty until then. The desk does not route on it.Tests
CI on this head (87a503a):
check,light gate,touched codeaf,touched restandwindows native testspass.Locally (macOS),
make pr-readydid not pass as one command. The PR is marked ready on the strength of CI and the comparison withdevbelow:cmd/codeafpassed fresh:cmd/codeafleg was refused insidepr-readyby 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). 15 tests failed. None is attributable to this change:TestAppTakeC1UnavailableModelUsesLocalDefaultOnce,TestDoReturnsAfterCancelledAccountingAndItsWriterStop,TestFactoryPersonStopInFlightWakeAndPersonChat,TestFactoryStartBadRepositoryFile,TestHostedTakeBackReachesAcquireWithoutSharedUploadsOrReleaseDelay,TestProcessCloseJoinsTheUsageWriter.origin/dev(ffeeb78) on the same Mac:TestAppFactoryPlanFilesAnswer,TestFactoryCommandStepRetryAfterOutOfAttempts,TestFactoryMeasuredOnlyPublication,TestFactoryRetryActionsDoNotReportStopped,TestFactoryStepRetryWaitsForPreviousLeaseCleanup,TestFactoryWriteUsesJobBaseAcrossAttempts,TestMemoryPauseResumeAfterTrailingProgress,TestMemoryPauseRetainsPendingForResume,TestMemoryPausedJobLifecycle(for examplewrite is not green: exit 127: the Factory's command steps do not find their shell on this Mac).The change's own tests pass locally: the ten
TestAppFactoryJobEvents*tests incmd/codeaf,TestFactoryJobEvents*ininternal/appwire,TestJobEvents*ininternal/factory/serve, and the outcome tests ininternal/factory/liveandinternal/factory/store.An engine built from this branch with #2617 and #2618 merged in announces
factory.job_eventsin hello and answersjob_events: truefromfactory.capabilities(captured fromcodeaf appin an empty home, no provider key).Changes after review
After this PR was marked ready, it was tested by hand on a built engine (a real
codeaf factory serve,codeaf runner joinandcodeaf app) and reviewed in three passes. Every defect found is listed below. Each was fixed on this branch with a test that fails before the fix and passes after it.The head is now 9d21962 (it was 87a503a). It adds 17 commits (fixes, their tests and their docs) and one merge of
dev. The merge was needed for #2618's rate-limit handling (fix 12). It also brings in #2609, #2612, #2613, #2614 and #2615, which are already ondev. A conflict in meaning was resolved in the merge commit 73fb26c (see fix 12). The "Tests" section above describes the earlier head, 87a503a.What factory.job and the wire now say differently
needs_you.sinceis when the wait began. It stays put while the job waits with the same need and question.since= the job's last change, so it moved with every rename, priority, budget or note. These waits are a start held for approval (ask_first), a take-over, an AgentField run's approval, a Stop undone at a gate or question, a failed merge, and a pull request with requested changes or a conflict.since= the landed run's end. On a Factory service it used the current step's time until the runner's done arrived. It now uses the time the publish failed, so a publish that is retried and refused again waits from its own refusal.since= the arrival of the runner's latest report. It now keeps the time the budget question was first asked.waiting_sinceis absent), and it offers Run as new; therun_as_newdoor accepts it. The step the change cut still shows the stop. Before, a reassigned job waited in Needs you as failed, "Stopped by you", with the cut run's question still on it. An unassigned job kept the question and never offered Run as new.GET /v1/job-eventscan answer HTTP 503 "try again" (the engine'sengineerror) when the first outcome it would return belongs to a repository GitHub could not answer for just now. That covers a 5xx, a failed connection or a spent rate limit. A page ends before such an outcome withmore: true.Item.NeedsAt(fix 10). It is not on the wire and is listed inapi.Lost.Pass 1: found by hand on 87a503a
needs_youon every later write: priority, budget, rename, and each runner report. A paused decision did the same.Store.updatecompared the new job withlive.OutcomeBasis, a partial copy of the prior job. The copy dropped each phase's kind, round and name, and the paused-recovery question, so the prior wait read as a different need (approve_stepagainstapprove_plan) or a different reason. The copy was needed onworkstreams/main. Ondev, since Carry the open app/v3 pull requests that integrate cleanly onto dev (16 of 22) #2597, the update callback already gets a decoded copy.OutcomeBasisis removed.Store.updatepasses the whole prior job, asStore.Savealready did.TestStoredNeedsYouOncePerWait(store).failed(codestopped).Runner.Stop, a step's own Stop, a no to the stop after a step, and a cancel out of attempts. Close and Reject recordedfailedbeside theirfinished, and so did the reset before a reassignment.stopped, andlive.Outcomesrecorded any new failure. The PR's "explicit stop is quiet" test never wrote that failure.failedoutcome followsfactory.JobState. A Backlog job records nothing. A job in Needs you always records it. A done job records any code exceptstopped.TestStoredPersonStopIsNoOutcome(store);TestServiceStopDoorsRecordNoFailedOutcome(lease, through the real doors);TestLoopPersonStopsRecordNoOutcomeandTestLoopStepFailureRecordsOneFailedOutcome(run);TestFailedOutcomeFollowsTheJobState(live).publish_failed.needs_you.sincemoved the same way.NeedsSinceanswers a failed publish's own time,PublishStatus.At.TestStoredPublishFailureOnce(store);TestJobEventsRecordEachRefusedPublishOnce(serve, the real worker HTTP road, including a retry that is refused again).Found by review of pass 1, fixed in pass 2
stoppedfailure, but several roads write that failure without marking the job as stopped by its person. On them factory.job showed Needs you / failed "Stopped by you" while the events recorded nothing.reportStepmarks a reported step failure codedstoppedas the person's stop on the served copy.failedfollowsJobState, as in fix 2.TestJobEventsStopRoadsAgreeWithFactoryJob(serve), with one row per road: stop at a gate and mid-step, a step's own stop, close, reject, a cancel when no runner holds the job, reassign running and at a gate, unassign, and a tool_error reported by a runner.TestJobEventsRunnerMadeStopsAgreeWithFactoryJob(serve): a cancel out of attempts and a no to the budget, each reported by the runner as the worker reports it.TestLoopAnswersThatStopLeaveTheJobInBacklog(run).budgetneeds_youeach time.reportQuestionstamped it with the report's arrival.reportQuestionkeeps the first time and writes no second ask line.TestJobEventsRecordAParkedBudgetOnce(serve).failure {code: stopped}on a Backlog job and on a closed or rejected job, so a client forming occurrences from a full read keyed it as a failed job.needs_you, or ondonewith a code other thanstopped;needs_youwith no failure.Also fixed in pass 2
live.HealOutcomes), so a gap resets a reader once.TestAppFactoryJobEventsGapResetWaits(cmd/codeaf),TestJobEventsGapResetsOnce(serve),TestHealOutcomesClosesOnlyAGap(live).repoAllowedalso says whether its answer is known. A page ends before an undecided outcome withmore: true, and a read that would start at one answers 503.TestJobEventsWaitOnAnUndecidedRepository(serve).Found by review of pass 2, fixed in pass 3
needs_you.sincemoved with it, becauseNeedsSincefell back to the job's last change.ask_first, an Undo of a Stop at a gate or question, controltake_over, and an AgentField run's approval wait. A search found three more: a failed merge, and a shipped pull request with changes requested or with a conflict.Item.NeedsAt, is set byfactory.NoteWaiton every store write (store.updateandStore.Save):sinceit already showed, so an upgrade records nothing new.NeedsSincereadsNeedsAtafter the job's own clocks (failure, publish, landed, ask, step) and before the last change, so a wait that already had a clock keeps exactly thesinceit had.NeedsAtis store bookkeeping: it is left out of the action-revision comparison and named inapi.Lost.TestJobEventsRecordAClocklessWaitOnce(serve), through the real doors and reports. Its rows areask_first, stop then undo at the plan gate,take_over, and an AgentField waiting run. Each row then takes four stamped writes.TestStoredClocklessWaitOncePerWait(store): a failed merge, changes requested, a conflict, a take-over.TestStoredWaitClockIsEachWaits(store): an old record, a new wait, a job that waits on nobody.waiting_sinceon a Backlog job, which a full read would key as a wait.unassignJobalso clears it, because no reset cuts a run that has already failed.TestJobEventsStopRoadsAgreeWithFactoryJob: reassign out of attempts; unassign at the plan gate; unassign out of attempts; unassign a failed job that still asks. Every row now also asserts nowaiting_sinceand noneeds_youunless the job is in Needs you, and the table's full-read model includes thewaiting_sincefallback.dev, sodevwas merged in, with no rebase and no force. factory.repo_issues, and a GitHub rate limit on a repository check answers retry #2618 makes a spent limit (any 429, or a 403 withX-RateLimit-Remaining: 0orRetry-After) return GitHub's retry time. It never caches the limit as a denial, and it remembers the limit per token.repoAllowednow returns (allow, known), and factory.repo_issues, and a GitHub rate limit on a repository check answers retry #2618's remembered-limit branch returned a bare false. In the merge commit that branch returns undecided, because a limit says nothing about the repository.retry_atis meant for routes that name a repository.TestJobEventsWaitOutAGitHubRateLimit(serve), with a spent primary limit and a 429 secondary limit. It fails if the remembered-limit branch answers known. factory.repo_issues, and a GitHub rate limit on a repository check answers retry #2618's own tests pass on the merged tree.RunEndedwas false and therun_as_newdoor refused it.factory.CanRunAsNewalso accepts a run that a reset cut: state New, the stream ended, and the current step's failure codedstopped.RunEndedis unchanged, so lease reset decisions and brief wording do not move.TestJobEventsAHandedOnRunOffersRunAsNew(serve): reassign a running job, reassign at the plan gate, and unassign a running job. A job that never ran must not offer it.Known limits left
Retry-AfternorX-RateLimit-Remaining: 0is still read as a denial and cached. That is factory.repo_issues, and a GitHub rate limit on a repository check answers retry #2618's rule, which judges by headers only.markRunnerLostleaves a standing ask on a failed job in Needs you, so factory.job can showwaiting_sinceon a failed job. It is not a Backlog road, and a retried step may need the ask. The full-read rule ignoreswaiting_sinceon a failed job.publish_failedwhen the engine is upgraded shows a newneeds_you.sinceonce, so the desk may wake once more for it.Tests on this head (9d21962)
go test -p 2, all ok: theinternal/factoryroot,live,store,lease,api,run,serveandworker.go build ./...andgo vet ./internal/factory/...are clean.cmd/codeafby name:^TestAppFactoryJobEvents, 11 of 11 pass.make test-quickexits 0.make test-packed-manual manual-gatesis ok (the manual changed).make changelog-checkreports every entry well formed.needs_youper plan-gate wait across later writes;publish_failedper refusal, including afterretry_publish;budgetoutcome across eight runner reports while parked;finishedfor a Close;failed: machine_lostfor a runner killed with SIGKILL;🤖 Generated with Claude Code