perf(orchestrator): a feedback round does not make the user wait for training - #156
Conversation
…training The training a feedback round triggers — classify the correction, propose a prompt change, run the candidate against the agent's fixtures, then run the current prompt against them again — ran BEFORE the session was marked ready_for_review. None of it can change the document: that was written, linted and persisted several lines earlier. So a user submitting feedback waited through a classify call, a train call and two passes over the fixtures for work about a FUTURE document, and every upload behind them waited too, since the run holds its max_concurrent_runs slot until it returns. It now runs after the status is set, which is the principle the contribution step below it already states and follows: never block the result. Contained, and that part is a bug fix rather than a precaution. A provider error in training marked the session `failed` — over a document whose output.html was on disk and whose Reader had signed it off. The e2e proves it: with the ordering reverted, the new step ends `run failed: openrouter 500`. Training is best-effort, and the delivered document is not its to revoke. run_complete stays after the training rather than moving up with the status. It is the run's own terminal marker and diagnostics measures a finished run's duration up to it, so writing it earlier would report a run as shorter than the time it held the machine. The one thing the ordering admits is a race with POST /close, which deletes the session's tmp tree: a caller closing the instant it sees ready_for_review can pull tmp/<id>/agents out from under a session-built agent's in-place training. That costs the training round, is logged as one, and is the same exposure runContribution has always had. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012w9QjtBNYLnyKgREdG1i37
There was a problem hiding this comment.
All six checks pass, and the change itself holds up: output.html, the lint JSON and final.json are all written above the moved updateSession, so nothing the training does can reach the delivered document; the try/catch fixes a real bug rather than papering over one; keeping run_complete below the training matches src/diagnostics.ts:203 measuring to the first terminal event; and e2e 9h is non-vacuous (feedback that actually changes the body, plus the stated mutation check). No blocking issues.
Non-blocking notes
1. Same-session overlapping runs are newly reachable, and the new comment names only the /close half of it. src/pipeline/orchestrator.ts:826 flips the status before what can be minutes of work, so the CAS at src/routes/sessions.ts:487
if (!store.claimSession(s.session_id, "ready_for_review", { status: "queued", phase: "extraction", error: null })) {now succeeds while run 1 is still inside runPipeline. RunQueue's cap is global, not per session (src/util/queue.ts:47, max_concurrent_runs: 2 in config.example.yaml:281), so run 2 does not wait for the slot — it starts alongside run 1's training. Two consequences worth naming beside the /close one:
saveExamplesis a read-modify-write with a plainwriteFileSync(src/pipeline/memory.ts:112). Run 2'sexamplesForPrompt(src/pipeline/extraction.ts:919) can read the bank mid-write, andloadExamples'scatch { return []; }(src/pipeline/memory.ts:105) turns that into "this page got no lessons" — silently, including thea11y_policyones.- if run 1's
runContributionthrows outside its own two try blocks (src/pipeline/contribute.ts:118—loadAgentis an fs read), the outer catch atsrc/pipeline/orchestrator.ts:894writesstatus: "failed"over a session run 2 has already delivered, or overclosed. That shape pre-dates this PR, but the window went from milliseconds to a whole training round.
Reached by a client that POSTs a second /feedback (or /close) the instant the poll flips — cheap for a scripted client, unlikely for a human, hence a note.
2. The race the comment does state is not reachable for page.md today. sessionBuilt is set only by src/agents/loader.ts:68 (an agent found in tmp/<id>/agents), and the only writer of that directory is src/pipeline/feedback.ts:685, which writes target.file only when target.sessionBuilt is already true. Nothing seeds a session-built page.md, so no POST /close can currently pull one out from under in-place training. Fine as forward-looking caution — worth knowing it documents a future exposure, not a live one.
3. GET /diagnostics can no longer see a hung training call — on every feedback round, not just an interleaved one. in_flight and in_flight_count are both gated on running = status === "running" || status === "queued" (src/diagnostics.ts:197, :231, :241), and the classify/train calls now run while the session reads ready_for_review. So a run stuck waiting on the provider inside training reports in_flight: null, in_flight_count: 0 while still holding its max_concurrent_runs slot and delaying every upload behind it — which is exactly the "is it hung?" question that file's header says in_flight exists to answer. A phase marker for the post-delivery work, or relaxing that gate to "has an unmatched model_call_start", would restore it.
Accessibility impact: none on any delivered document — the HTML is written, linted and persisted before anything this PR moves; the only path from here to output is the latent torn read of the a11y-lesson bank in note 1.
…than noted Three consequences of running the training after the status flips, all raised by the review of the ordering itself. `GET /diagnostics` stopped being able to see a hung training call. `in_flight` and `in_flight_count` were gated on the SESSION status, and training now runs while the session reads `ready_for_review` — so a run stuck on a provider inside it reported nothing in flight while still holding its max_concurrent_runs slot and delaying every upload behind it, which is the one question that field exists to answer. The gate is now the window itself: ready_for_review with no terminal line written yet. Not simply "no terminal line", because failStaleSessions rewrites the status of a run whose process died and cannot append to its own log — that run's last open call would then hang forever in the report, which is the phantom the old gate prevented and the existing test caught. The shared lesson bank is now written atomically. It is keyed by agent file rather than by session, and max_concurrent_runs allows more than one run at a time, so a page being extracted in one run can read the bank while another records a lesson to it. A plain writeFileSync is not atomic and loadExamples answers a partial read with [] — that page would be extracted with no lessons at all, accessibility-policy ones included, and nothing would say so. This one predates the ordering change; the window is what made it worth closing now. And runContribution is contained the way the training above it is. Its own two failure paths were already caught, but what is left outside them is still an fs read, and the outer catch would answer it by writing `failed` over a delivered document — the same wrong answer the training used to give. The comment now names the whole window rather than half of it: a client acting on ready_for_review can close the session or start a second run, and the run queue's cap is global rather than per session, so neither waits. The /close race it described is also corrected to what it is — forward-looking, since nothing seeds a session-built page.md today. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012w9QjtBNYLnyKgREdG1i37
There was a problem hiding this comment.
All six checks pass. Three of the four things I noted on 1e5d244 are addressed; the fourth (note 3, in_flight during the widened window) is not, because the gate that was added for it cannot fire.
Blocking
src/diagnostics.ts:243 — the new active gate is never true on a feedback round, so in_flight is still null in exactly the window this PR widened, and the test added for it cannot fail.
const active = running || (ctx.status === "ready_for_review" && !terminal);terminal is events.find(...) over the whole session log (src/diagnostics.ts:203), and that log is one append-only file per session (src/store/paths.ts:38, src/store/runlog.ts:14) — e2e 9h reads every run from it and asserts run_complete count >= 2.
A feedback round is only accepted when the session is already ready_for_review (src/routes/sessions.ts:467), which run 1 reached by writing the status and then run_complete (src/pipeline/orchestrator.ts:361 — run 1 has no args.feedback, so no training sits between the two). So by the time run 2 is inside the training block, run 1's run_complete is in the log, terminal is truthy, and active === running === false: in_flight: null, in_flight_count: 0, identical to before the change.
The only state where active differs from running is a first run's sub-millisecond gap between updateSession and log.event("run_complete") — where no training runs.
Input that reaches it: GET /v1/sessions/{id}/diagnostics during any feedback round's training window. The run holds its max_concurrent_runs slot on a possibly-hung provider call and the field that exists to say so reports idle.
test/diagnostics.test.ts:96 passes only because its fixture log is a single run_start with no prior run_complete — a state a feedback round cannot produce — so it stays green whatever this gate does.
Scoping the terminal lookup to the current run (from the last run_start index onward) fixes both. The same first-match assumption also makes endRef measure a multi-round session up to run 1's run_complete, which is pre-existing but the same root cause — and worth fixing in the same line, since the new comment at :230-242 and at orchestrator.ts:353-359 both rest on run_complete being this run's terminal line.
Withdrawn from my review of 1e5d244
Note 1's first bullet — "run 2's examplesForPrompt can read the bank mid-write" — was wrong for a single Iris process: loadExamples and saveExamples are both synchronous fs calls on one JS thread, so two concurrent runs in the same process cannot interleave a read with a write. Withdrawn.
Non-blocking notes
1. src/pipeline/memory.ts:118-126 is correct, but the race it names isn't the one it fixes. Per the withdrawal above, "a page being extracted in one run can read this file while another run is recording a lesson to it" cannot happen in one process. The real exposure is two Iris processes sharing data_dir; there the rename does remove the torn read, but leaves the lost update — loadExamples → mutate → saveExamples is still read-modify-write, so B's rename discards A's just-recorded lesson entirely rather than half of it. Relatedly, renameSync over an open destination can fail with EPERM on Windows (Node uses MoveFileEx, not ReplaceFile as the comment says), and that throw is now swallowed by the new training catch.
2. Neither new event surfaces in GET /diagnostics. errors[] keys on run_failed || ok === false (src/diagnostics.ts:324), so feedback_training_failed and contribution_failed are absent from it. In e2e 9h the provider 500 still shows through its own model_call, but the non-model throw the new catch at orchestrator.ts:379-382 exists for (the loadAgent fs read) leaves diagnostics looking clean and is visible only in the raw ndjson.
3. No test covers the runContribution catch (orchestrator.ts:379-382); the training catch has e2e 9h.
Fixed since 1e5d244
Note 1's second bullet (runContribution is now contained, so a throw there can no longer write failed over a delivered session), note 1's naming complaint (the comment now states the /feedback half of the window), and note 2 (the session-built-agent race is labelled forward-looking).
Accessibility impact: none on any delivered document — the HTML is written, linted and persisted before everything this PR moves or contains; the atomic lesson-bank write only affects which lessons a future run's prompt carries, and only across processes.
…e first one in the file The gate added for the widened post-delivery window could not fire, so `in_flight` was still null in exactly that window and the test written for it could not fail. A session's log is one append-only file across every round it has, and `terminal` was `events.find(...)` over the whole of it — the FIRST `run_complete`. A feedback round is only accepted on a session that already reached ready_for_review, so run 1's terminal line is always in the file by the time run 2 exists: the gate asked "did the first run finish?", which is always yes. The only state where it differed from the old behaviour was a first run's sub-millisecond gap between the status write and its own `run_complete`, where no training runs. The test passed only because its fixture had no earlier round in it — a state a feedback round cannot produce. The terminal lookup, the open-call matching and the elapsed clock are now scoped to the current run (from the last `run_start`). That fixes three things at once: the gate fires where it was meant to, an earlier round's abandoned call is no longer reported as what this run is stuck on, and a multi-round session stops measuring its elapsed time to the first round's completion — which was pre-existing, and the same root cause, and made `concurrency_factor` divide every round's call time by one round's wall clock. `elapsed_ms` now runs to `now` while the run is still working rather than stopping at the last logged event, so it agrees with the `waiting_ms` beside it instead of reporting a stalled run as finished. The two post-delivery failures also reach `errors[]`. They report rather than raise, so the `ok === false` rule — which only sees model calls — could not see them: the fs read those catches exist for would have left a run looking clean. And the atomic-write comment claimed a race that cannot happen. Both halves of that file are synchronous fs calls on one thread, so two runs in one process cannot interleave a read with a write. The real exposure is a second process over the same data_dir, the lost update it does NOT fix is now stated, and the Windows claim is corrected: renameSync is MoveFileEx, which can fail with EPERM rather than replacing an open destination. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012w9QjtBNYLnyKgREdG1i37
There was a problem hiding this comment.
All six checks pass. The blocking finding from a2db9c5 — the active gate being dead because terminal was the first run's — is fixed: src/diagnostics.ts:213-215 scopes the lookup to the slice from the last run_start, and the two new fixtures in test/diagnostics.test.ts (a feedback round is read against its OWN terminal line / an earlier round's abandoned call…) are the two-round shapes that a single-run fixture could not produce, so the gate can now fail if it regresses. Scoping openCalls to currentRun as well is right, and balanced — the pre-run_start scope call has both its start and its end outside the slice. errors now carrying feedback_training_failed / contribution_failed closes note 2 from that review, and the runContribution catch closes note 3.
Non-blocking notes
1. The failStaleSessions half of the new gate does not cover the window this PR creates, so the phantom hang the comment guards against is reachable in it. src/diagnostics.ts:229
const active = running || (ctx.status === "ready_for_review" && !terminal);The rationale above it (:222-228) is that a dead process is caught by its status being rewritten. But failStaleSessions only rewrites running/queued:
UPDATE sessions SET status = 'failed', error = 'interrupted (server restarted)', ...
WHERE status IN ('running','queued')(src/store/db.ts:606-609)
A process killed inside the post-delivery training window — the window this PR widens from a moment to a classify call, a train call and two fixture passes — leaves status = 'ready_for_review' with the current run's run_start in the log and no run_complete. On the next boot failStaleSessions skips that row, so active is true permanently: GET /v1/sessions/{id}/diagnostics reports the abandoned feedback call as in_flight with waiting_ms growing without bound, and elapsed_ms climbs off nowIso forever (:230, :442). Latent in the sense that it needs a restart/OOM during a feedback round's training rather than ordinary traffic, but it is the same false answer the gate was written to avoid, just entering by the status door instead of the log door. Either extend failStaleSessions to ready_for_review rows whose run never terminated, or bound the window with an event of its own (a post_delivery marker written before the training and closed after it) so the gate does not rest on a status nobody rewrites.
2. Two concurrent runs on one session re-blind in_flight for the second one. POST /feedback can claim the session during run 1's training window (src/routes/sessions.ts:487) and the queue cap is global, so with max_concurrent_runs: 2 run 2's run_start is appended before run 1's run_complete. lastIndexOf("run_start") then picks run 2, and run 1's trailing run_complete sits inside currentRun — so terminal is truthy and active === running === false throughout run 2's own training window, which is the state the new gate exists for. Same client behaviour as the overlapping-run window you already document at orchestrator.ts:1499-1517; noting it because the log is only append-ordered per process, not per run, and the new per-run slicing assumes it is.
3. docs/API.md:634 now understates errors. "errors lists failed calls" — with src/diagnostics.ts:352-359 it also lists feedback_training_failed and contribution_failed, which are the two entries that are not calls and are the reason the filter was widened.
Accessibility impact: none on any delivered document — output.html, the lint JSON and final.json are all written above the moved updateSession, and the atomic lesson-bank write only changes which lessons a future run's prompt carries (and only across processes).
…unt rounds rather than slice them Two holes the widened post-delivery window left in the new gate, and the doc line that described the old `errors`. A process killed inside that window leaves a row nobody corrects. The boot sweep rewrites `running` and `queued` rows and deliberately not `ready_for_review` ones — the document IS delivered and that status is right — so "ready_for_review with no terminal line" was permanently true for such a session, and its abandoned call was reported as hanging for ever with waiting_ms and elapsed_ms climbing off the clock. That is the phantom the gate was written to avoid, entering by the status door instead of the log door. The claim is now bounded by what a call can actually do. Each adapter abandons a stream at an absolute 15-minute ceiling and OpenRouter retries at most three times, so ~45 minutes is the longest a call can legitimately stay open; past an hour, an open call is not a slow call but a process that is gone, and the run reports as over. Derived from the adapters' own numbers rather than picked. And rounds are now COUNTED rather than sliced. A client can claim a session with POST /feedback during another round's post-delivery window, and above a max_concurrent_runs of 1 the second round's run_start is then appended before the first round's run_complete — so slicing from the last run_start handed the slice a trailing terminal line belonging to the other round, and read the still-working round as finished. As many terminal lines as starts means every round is done; a count cannot be fooled by the interleaving. docs/API.md said `errors` lists failed calls. It now also lists the two failures that are not calls, which is the reason the filter was widened. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012w9QjtBNYLnyKgREdG1i37
There was a problem hiding this comment.
All six checks pass. Since e4e0019 only docs/API.md, src/diagnostics.ts and test/diagnostics.test.ts changed, and all three notes from that review are addressed:
- The
failStaleSessionshole (note 1) is bounded.MAX_PLAUSIBLE_CALL_MS(src/diagnostics.ts:197) is derived correctly —src/providers/openrouter.ts:28,32is 15 min × 3 attempts andsrc/providers/bedrock.ts:44is 15 min, and nothing insrc/feedstimeouts.maxTotalMsfrom config, so an hour is genuinely above what a caller can produce today. - The interleaved-round blinding (note 2) is fixed by counting rather than reading the slice (
src/diagnostics.ts:231-233), androunds are counted, so an interleaved second round is not read as finishedis the two-run_start-before-one-run_completeshape a slice fixture cannot express. docs/API.md:632-635now says whaterrorsactually carries.
Non-blocking notes
1. The residual half of note 1: unfinished is counted over the whole file, but abandoned is scoped to currentRun, so a round with no terminal line still pins active on when the slice has no open call to bound.
const abandoned = oldestOpenAt !== null && ms(oldestOpenAt, nowIso) > MAX_PLAUSIBLE_CALL_MS;
const active = running || (ctx.status === "ready_for_review" && unfinished && !abandoned);(src/diagnostics.ts:282-283, with openCalls/oldestOpenAt built from currentRun at :248,259)
Two shapes where oldestOpenAt is null and so abandoned can never fire:
- the process is killed in the post-delivery window between model calls — after
learnFromFeedbackreturns, during a fixture read, or inrunContribution'sloadAgentfs read (src/pipeline/orchestrator.ts:372); - the process is killed mid-call, and a later
POST /feedbackround then runs and completes —/feedbackis accepted because the status is stillready_for_review, andlastIndexOf("run_start")puts the orphaned start outsidecurrentRun.
Either way roundsStarted > roundsEnded holds for the life of the row and no sweep touches a ready_for_review status, so active stays true. in_flight/in_flight_count are correctly null/0 (nothing is open in the current run), but endRef = nowIso (:284) means elapsed_ms counts from the session's first event to now without bound (:471), concurrency_factor decays toward 0 (:489), and the last phase's entry in phase_durations_ms grows forever (:375) — an idle, delivered session reading as one that has been working for days. Latent: it needs a process death inside a feedback round's post-delivery window, and the field the gate was written for is now right. Bounding endRef by lastEventAt when the current run has no open call, or writing a post_delivery marker that closes, would cover both shapes.
Accessibility impact: none on any delivered document — this commit touches only the diagnostics summary, its tests and the doc that describes it; output.html, the lint JSON and final.json are still written above the moved updateSession.
…lly pending The open-call ceiling bounds a round killed MID-call. It cannot bound one killed BETWEEN calls — after the classify call returns, inside a fixture read, in the contribution step's own fs read — because there is nothing open to age out. That round never terminates, no sweep rewrites a ready_for_review status, and the session was therefore measured to `now` for ever: elapsed_ms counting up for days, concurrency_factor decaying toward zero, the last phase's duration growing without end. An idle, delivered session read as one that had been working since the day it died. A running session still measures to now, because a run between two calls is still a run. In the post-delivery window it takes an open call as well — that window is the one place a run can end without saying so, and a session's last event is the last thing known to have happened. in_flight was already right for this shape; this is the clock beside it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012w9QjtBNYLnyKgREdG1i37
There was a problem hiding this comment.
All six checks pass. Since bb8ee50 only src/diagnostics.ts and test/diagnostics.test.ts changed, and the single note from that review is addressed.
Fixed: endRef no longer runs to now for a round that will never terminate. src/diagnostics.ts:296-297 requires an open call in the post-delivery window, and both shapes I named are covered — killed between calls (oldestOpenAt === null, so endRef = lastEventAt) and killed mid-call followed by a completed later round (endRef = terminal?.ts, round 2's own line). a round killed between calls does not count the clock up for ever (test/diagnostics.test.ts:171) is the fixture that fails without the change: active is still true there, so the old line measured three days.
Non-blocking notes
1. The bound now rests on an open model call, but the longest non-model step in that window is a GitHub request with no timeout — so elapsed_ms freezes for a live run that is genuinely holding its slot. src/diagnostics.ts:296
const pending = running || (active && oldestOpenAt !== null);proposeAgentUpdatesFromFeedback files the update issue inside the training block, above run_complete (src/pipeline/orchestrator.ts:349, src/pipeline/feedback.ts:765-779): an Octokit title search, then a create-or-comment, on new Octokit({ auth: token, baseUrl: apiBase }) (src/github/issue.ts:256) with no request.timeout and no signal. None of it is a model_call, so openCalls is empty and pending is false while it runs. With a token configured — the deployed configuration for the learning path — a stalled GitHub connection leaves the run holding its max_concurrent_runs slot indefinitely while GET /v1/sessions/{id}/diagnostics reports in_flight: null, in_flight_count: 0, and now an elapsed_ms frozen at the last fixture call, a concurrency_factor computed off that frozen elapsed, and the last phase's duration fixed — an occupied queue slot reading as a finished run.
Before this commit elapsed_ms was right in that case and wrong in the dead-process case, so the direction of the trade is the better one. What is missing is that the ceiling which makes the dead case safe is not applied to the shape that has no open call to age out. MAX_PLAUSIBLE_CALL_MS works there too: unfinished && ms(lastEventAt, nowIso) <= MAX_PLAUSIBLE_CALL_MS covers both — a live gap between calls measures to now, and a killed round stops climbing an hour after its last event, with no open call required.
Latent in the sense that it needs GitHub filing to hang rather than ordinary traffic, and no delivered document is affected either way.
2. The comment at src/diagnostics.ts:285-295 states the rule but not its cost. It lists the dead-process shapes the open-call requirement handles; it does not say that a live run stalled in a non-model step in this window is measured to its last event. If the trade in note 1 is intended, one clause saying so keeps the rationale matching the code — these comments are the only place the gate's reasoning lives.
Accessibility impact: none on any delivered document — this commit touches only the diagnostics summary and its tests; output.html, the lint JSON and final.json are all written above the moved updateSession.
…ill working Keying the clock on an open MODEL call freezes it on the one run that most deserves to be counted. The longest step in the post-delivery window may not be a model call at all: filing the agent-update issue is a GitHub request with no timeout of its own, and a stalled one holds the run's max_concurrent_runs slot while openCalls is empty — so an occupied slot read as a finished run, with elapsed_ms frozen at the last fixture call and concurrency_factor computed off it. Recency covers both shapes with one rule, and it is the ceiling that was already derived for the other one: a run in this window counts to now while its last event is within the hour a call can legitimately take, and stops climbing after that. A live gap between calls measures to now whatever it is doing; a killed round stops an hour after whatever it last managed to write, with no open call required. The cost is now stated where the rule is: a live run stalled past the ceiling in a step that logs nothing is measured to its last event. That is the right way round — past an hour of silence "the process is gone" is the better guess, and it is the only one that terminates. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012w9QjtBNYLnyKgREdG1i37
There was a problem hiding this comment.
All six checks pass. Since 4185633 only src/diagnostics.ts and test/diagnostics.test.ts changed, and both notes from that review are addressed.
Note 1 is fixed — the bound no longer rests on an open model call. src/diagnostics.ts:305:
const pending = running || (active && ms(lastEventAt ?? undefined, nowIso) <= MAX_PLAUSIBLE_CALL_MS);Both directions hold: a round killed between calls stops measuring at its last event three days out, and a live run ten minutes past its last event — the GitHub-filing shape, which logs nothing while it runs — still measures to now. The added second half of a round killed between calls does not count the clock up for ever (test/diagnostics.test.ts:191-200) is the mutation-catching fixture: under the old oldestOpenAt !== null form live.elapsed_ms is 2000, not >500_000.
Note 2 is fixed — src/diagnostics.ts:300-304 now states what the trade costs.
And the residual I raised on bb8ee50 (unfinished counted file-wide while abandoned is scoped to currentRun, so active can pin on for the life of the row) no longer has a user-visible effect: active feeds only inFlight/inFlightCount — both correctly null/0 when the current run has no open call — and pending, which is now bounded by recency either way.
Non-blocking notes
1. The contribution window after run_complete is still measured as finished — the same blind spot, one step later. src/diagnostics.ts:233 sets unfinished false as soon as roundsEnded == roundsStarted, and run_complete is written at src/pipeline/orchestrator.ts:361, above runContribution (:379), which files through the same untimed Octokit client (src/github/issue.ts:256) as the filing inside the training block. So a stalled contribution leaves the run holding its max_concurrent_runs slot while GET /v1/sessions/{id}/diagnostics reports in_flight: null, in_flight_count: 0 and an elapsed_ms frozen at run_complete — an occupied queue slot reading as a finished run. Pre-existing and untouched by this PR (runContribution has always run past run_complete), and the placement of run_complete is deliberate and correctly argued for the duration measurement; noting it only because the PR reasons explicitly about that line and newly surfaces contribution_failed from the step below it, so the two post-delivery steps now differ in whether diagnostics can see them hang.
Accessibility impact: none on any delivered document — this commit touches only the diagnostics summary and its tests; output.html, the lint JSON and final.json are all written above the moved updateSession.
§7 of `docs/API.md` gains the seventeen events it had no section for — the feedback-training family, the two contribution ones, the orchestrator's two containment catches and the calibration harness's — so coverage goes from 94 documented with 17 missing to all 111. That completes #406 item 2 across three PRs (#414, #415, this one). The paragraph's claim changes with it, from "the index is not the whole log" plus a count of the gap back to "the index is the whole log" — which is the claim that paragraph replaced when it was found wrong by 40 events. It is safe to make again because the test asserts the coverage instead of counting it: an event with no section fails by name. Five facts the field names do not carry, each read off the emit site: `failures` is a count on `regression_gate` (`failures.length`, feedback.ts:748) and the list of strings behind it on `agent_update_blocked` (`gate.failures`, :899) — and both lines are written for the same blocked update, the same collision `page_corrected` and `page_links_correction_rejected` have over `problems`. `agent_update_blocked` is one event with two shapes, told apart by a `reason` only the eval-gate site carries. With no `reason` it has `failures`; with `reason: "eval_regression"` it has none. `regression_gate` is ABSENT when the gate had nothing to check — an agent with no fixtures directory passes without a line — so an `agent_updates_proposed` with no gate line above it is a proposal checked against nothing. `cases` counts fixture FILES, and a fixture whose JSON or image is gone contributes to neither `failures` nor `meanCoverage`. `agent_issue`'s `url` is not always a URL: a duplicate title carries the literal "(duplicate — skipped)", which is the only thing separating the two outcomes. `contribution_failed` and `agent_issue` come AFTER `run_complete`, so the run's terminal marker is not the log's last line. `run_complete`'s section now says so. `agent_trained` cannot fire today. Its branch is behind `target.sessionBuilt`, which `loadAgent` sets only from a file in `tmp/<id>/agents`, and the only line in `src/` that writes such a file is that branch itself. The test pins that loop shut, so it is a checkable fact rather than a claim about unreachable code. Two source comments corrected where they contradict the code the new sections describe. feedback.ts's regression-gate comment still said the gate runs while the session is not yet `ready_for_review` and the user waits for it, which #156 made false by moving training past delivery. And the `agent_trained` branch's comment promised a "new-agent PR opened on close", which exists in neither half: contributions are issues, and `runContribution` skips a type whose agent already exists in tmp. FIVE REVIEW ROUNDS, all approved, six notes, every one true and every one taken. Two sections omitted an `agent` field — worse on `agent_update_issue_skipped`, whose neighbour says outright that it has none, so the omission read as the same statement. The other four are one finding chasing its own fixes, which is the part worth keeping. The coverage claim had a blind spot the test could not see: a COMPUTED event name. `log.event(type, data)` at orchestrator.ts:92 and calibrate.ts:448 is invisible to every literal-name search, so "the index is the whole log" would have gone quietly false the first time anything wrote `ctx.log.event(kind, …)`. The assertion added for it was then wrong three times, each time in the sentence the previous fix created: - keyed on line numbers, it failed any PR that shifted orchestrator.ts:92, with a message accusing it of emitting under an unreadable name; - widened to `onEvent(` so a missing optional chain could not hide a name, it matched a method SIGNATURE, which would accuse a type-only refactor of the same thing; - fixed by requiring a receiver — but the prefix was SHARED, so that also blinded the literal-name search to a receiverless `onEvent?.("x")`, and there the failure direction inverts: an undocumented event simply stops being counted, with no message. Exactly the silent staleness the whole test exists to prevent. The two searches are now separate patterns at deliberately different widths: wide where a miss is silent, narrow where a miss only costs a warning. Both name classes are `[^"]` for the same reason, so an off-convention `log.event("foo-bar")` fails loudly as undocumented instead of dropping out of the claim. Fourteen mutations across the five rounds, each failing with the intended message and each reverted to a byte-identical blob. One is worth recording as a limit rather than a win: `failures.slice(0).length` on `regression_gate` keeps the units a count and still fails, so that assertion is pattern-shaped, not semantics-shaped — it asks someone to look, like the append-shape check above it, and is not a units oracle. tsc clean, 1575 pass / 0 fail, e2e all endpoints passed. Added prose is 118 sentences, median 19 words, 11 at 40+ and none at 60+, against §7's existing median of 39 with 49% at 40+. Refs #406. Co-authored-by: bbertucc <46652+bbertucc@users.noreply.github.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Summary
The training a feedback round triggers — classify the correction, propose a prompt change, run the candidate against the agent's regression fixtures, then run the current prompt against them again — ran before the session was marked
ready_for_review.None of it can change the document. That was written, linted and persisted several lines earlier. So a user who submitted feedback waited through a classify call, a train call and two passes over the fixtures for work about a future document — and every upload behind them waited too, since the run holds its
max_concurrent_runsslot until it returns.It now runs after the status is set. That is the principle the contribution step immediately below it already states and follows: never block the result.
The containment is a bug fix, not a precaution. A provider error anywhere in training marked the session
failed— over a document whoseoutput.htmlwas on disk and whose Reader had signed it off. The new e2e step proves it: with the ordering reverted and nothing else changed, that step endsrun failed: openrouter 500: e2e: training call refused.run_completestays after the training rather than moving up with the status. It is the run's own terminal marker, andsrc/diagnostics.tsmeasures a finished run's duration up to it — writing it earlier would report a run as shorter than the time it actually held the machine.What the widened window exposes, closed rather than noted
Moving the status flip ahead of minutes of work widens a window that already existed (
runContributionhas always run past the status): a client acting onready_for_reviewcan close the session or start a second run, and the run queue's cap is global rather than per session, so neither waits. Three consequences, all fixed here:GET /diagnosticscould no longer see a hung training call.in_flightandin_flight_countwere gated on the session status, and training now runs while the session readsready_for_review— so a run stuck on a provider in there reported nothing in flight while still holding its slot and delaying every upload behind it, which is the one question that field exists to answer. The gate is now the window itself:ready_for_reviewand no terminal line written yet. Not simply "no terminal line" —failStaleSessionsrewrites the status of a run whose process died and cannot append to its own log, and that run's last open call would then hang forever in the report. The existing test for exactly that caught my first attempt.max_concurrent_runsallows more than one run at a time, so a page being extracted in one run can read the bank while another records a lesson to it. A plainwriteFileSyncis not atomic andloadExamplesanswers a partial read with[]— that page would be extracted with no lessons at all, accessibility-policy ones included, and nothing would say so. This one predates the ordering change and is reachable today with two concurrent sessions; the widened window is what made it worth closing now.runContributionis contained the way the training above it is. Its own two failure paths were already caught, but what is left outside them is still an fs read, and the outer catch would answer it by writingfailedover a delivered document — the same wrong answer the training used to give.The
POST /closerace the comment described is also corrected to what it is: forward-looking rather than live, since nothing seeds a session-builtpage.mdtoday, so the only writer of that tmp path never runs for the agent this trains.Testing
npm run typechecknode --test "test/*.test.ts"— 606 passing, 3 skipped, 0 failing./test/e2e.sh— 94 assertions pass, none failPOST /__fail-trainingcontrol on the mock provider that fails theTASK: classifycall with a 500: the round is accepted, the session still reachesready_for_review, the document is still served (200), the round's ownrun_completearrives after that (the session was ready while training was still in flight — which is the point), the failure is in the run log asfeedback_training_failed, andrun_completefollows the training rather than preceding it.src/pipeline/orchestrator.tsmakes step 9h fail withrun failed: openrouter 500— so the step tests the change rather than passing alongside it. (The first draft of this step was vacuous — feedback that leaves the body unchanged makes both training functions return early, so nothing ran; it now uses feedback that actually changes the document.)test/diagnostics.test.tsgains the post-delivery case: a call still open while the session readsready_for_reviewand no terminal line is written is reported in flight, and the same log withrun_completereports nothing. The existing "a finished run is not hung" case still passes, which is what bounds the new gate.test/memory.test.tsgains an atomicity case: after eight recorded lessons the bank on disk parses, holds all eight, and no temporary file is left beside it.Checklist
🤖 Generated with Claude Code
https://claude.ai/code/session_012w9QjtBNYLnyKgREdG1i37