feat(sessions): report amendment/supersession - #14
Conversation
roughcoder
left a comment
There was a problem hiding this comment.
REQUEST-CHANGES
-
[blocker] apps/server/src/persistence/Migrations.ts:121 — Do not merge/deploy this before PR #13. The runner explicitly runs only migration IDs greater than the latest recorded ID (Migrations.ts:148-160). If 048 lands first, its DB records 48; later PR #13’s 047 is permanently skipped, leaving its required table absent. The “reserved” comment is not sufficient protection. Keep the number, but make #13 a merge/deploy prerequisite (or otherwise make the runner safe for out-of-order additions).
-
[major] apps/server/src/persistence/Layers/ProjectionThreadReports.ts:172 —
post_reportpermits multiple reports to supersede A, but the derived reverse chooses the earliest one. With A→B then A→C,read_report(A)says B is the current account whileread_report(threadId)returns C as latest. This violates the documented “amendment becomes current account” / follow-the-chain contract and loses C from A’s navigation. Reject an already-superseded target (or define/implement an unambiguous latest-wins or multi-successor contract); add fork and multi-hop-chain tests. -
[minor] apps/server/src/mcp/toolkits/sessions/handlers.ts:1011 — Same-thread validation is a projection lookup, not command/decider enforcement. Two concurrent amendments can both validate A before either projection update, creating the fork above; internal command callers can also persist arbitrary/cross-thread links. Put the invariant at the serialized authoritative boundary, not solely in the MCP handler.
-
[minor] apps/server/src/orchestration/Layers/SessionSpawnReactor.test.ts:360 — Delivery tests are formatter-only. They do show one amended string, but do not exercise reactor dispatch to prove one parent turn (not a normal plus amended duplicate), nor the stated resurrection flow: system terminal report → resumed agent report superseding it. Add an integration-style reactor/handler assertion.
Verified read-only: reverse is indexed correlated subquery (per returned report, no full scan); list/read order is created_at/report_id; read_session/read_report surface links; hasReport remains an existence check (appropriately true for superseded reports); optional event fields replay safely; archived threads cannot post because requireShell uses active shells. I did not run tests, typecheck, installs, or servers.
|
Fix round pushed as 1. Amendment forks (major) — fixed, linear chains only. 2. Validation moved into the decider (required with #1) — done. While wiring this I checked a dependency worth recording: the decider's check is only sound because 3. Tests — all three added.
Migration ordering: understood, nothing done here — #13's 047 merges before this 048, and I renumber at rebase only if that order slips. Validation (per the amended resource protocol — no root suite; the serialized merge gate owns CI parity):
Worth flagging honestly: my first pass at this round had the tests passing but the package failing to typecheck with 51 errors — vitest does not typecheck, so the green test run hid it. Root causes were real, not cosmetic: |
roughcoder
left a comment
There was a problem hiding this comment.
Delta verdict: APPROVE — merge-ready after #13.
Original findings:
- [major] RESOLVED — linear-chain enforcement is in the decider (
apps/server/src/orchestration/decider.ts:1465-1500). The engine has one queue worker and advances its command read model only after append/project/commit (Layers/OrchestrationEngine.ts:90-215,303-320), so a second queued amendment sees the winner; same-batch pre-image is not possible. - [minor] RESOLVED — both handler pre-check/recheck scope
listByThreadIdto the calling thread (handlers.ts:1016-38,1061-86), and the decider reads only the required thread’s folded reports. Structured winner/head ids therefore do not disclose another thread’s report ids.
New evidence: the amended delivery test drives the reactor and asserts exactly one parent turn with the AMENDED prefix (SessionSpawnReactor.test.ts:478-510). Resurrection proves the old system report receives the derived reverse link and the later agent report is the ordered latest row (ProjectionRepositories.test.ts:280-319). hasReport (same-thread EXISTS) and terminal synthesis dedup (any report) coherently count superseded history.
[nit] Resurrection does not directly exercise read_session/read_report(threadId), hasReport, or reactor dedup after the amendment; source paths are correct (handlers.ts:716-17,1200-08; SessionSpawnReactor.ts:297-305).
Spot-checked 0628e8187: collateral production changes are limited to supersession/error plumbing; the rest is tests/docs/type-safe mocks. Read-only review; no tests/typecheck/lint run.
0628e81 to
824c63c
Compare
|
Rebased onto Conflict and resolution: exactly as you predicted, one file — One non-mechanical bit: my registry carried a comment saying "47 is reserved by a sibling wave-2 slice; ids only have to be ordered, not contiguous". That's now false and misleading, so I dropped it rather than carrying a stale note past its expiry. The migrations test file did not conflict — 047 touches queued-turn receipts, a different table from Verification on the rebased head:
I deliberately ran 047's migration test alongside 048's rather than only my own: the thing a migration rebase can actually break is ordering, and 048's test migrates through the full chain, so it now exercises 047 landing before it for real. Pushed with |
…opes post_report gains supersedesReportId so a session can amend an account it already gave — the case that motivated this is a queued instruction landing after the child reported, leaving a stale report as the record. Both fields are optional on SessionReport and on the thread.report.post command / thread.report-posted payload, so every already-persisted report event replays unchanged. supersededByReportId is deliberately absent from the event: at post time no superseding report exists yet, so it is derived on read paths instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Migration 048 adds projection_thread_reports.supersedes_report_id plus the index every read path uses to resolve the other direction. Only the forward link is stored: an amendment never rewrites the row it supersedes, so the projection stays append-only and "who superseded me" is a correlated subquery rather than a flag that could drift out of sync. Migration id 48 leaves a gap at 47, which a sibling wave-2 slice reserves; ids only have to be ordered, not contiguous. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
post_report validates the reference before dispatching: it must name a report on the calling thread. A report is a session's account of its own work, so amending another session's report is refused — with the same message as an unknown id, so the denial cannot double as a probe for report ids elsewhere. A dangling link would be worse than a rejection: no reader could follow it. Both ends travel outward. An amending report's parent notification leads with "AMENDED report (supersedes ...)" before the summary, because a parent that already acted on the superseded report has to see that first. read_report on a superseded report still serves its body, plus supersededByReportId and a supersededNotice sentence — a caller paging an old report cannot be relied on to notice a field it was not looking for. The spawned-session instructions now state the rule directly: an instruction arriving after you reported means post an amending report, never claim retroactive compliance. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review found A→B and A→C was possible, leaving reverse navigation and latest-report selection free to disagree about which report is current. Superseding an already-superseded report is now refused and the caller is handed the head of the chain, which also settles the concurrent-fork race: the second writer loses with an error naming where to re-attach. The authoritative check lives in the decider, not the toolkit. Handler-level validation cannot prevent a fork — two amendments can both pass their pre-checks before either dispatches — whereas the decider runs against the folded read model serialized with command processing, and also covers internally dispatched report posts that never reach the toolkit. The toolkit keeps a pre-check purely for error quality, and re-reads the chain when a dispatch is rejected so a race loser gets the same structured error rather than a generic dispatch failure. Both sides share one implementation and one wording so they cannot disagree. Tests: fork rejection including the race shape, chain-head reporting through a longer chain, a one-turn reactor run asserting AMENDED delivery reaches the parent (not just the formatter), and synthesized-report resurrection — a Phoenix terminal report superseded by the agent's own account after resume. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
824c63c to
ff81ae5
Compare
|
Rebased onto Conflicts and how they were resolved. Five files, and #15's insertion points sat exactly on mine, so most hunks were additive-vs-additive:
Test expectations needed no union edits: #15's report assertions and mine touch disjoint fields, and #15's new One thing worth flagging: when I started, this worktree had an unfinished Verification on the rebased head:
I pulled #15's own test files into my targeted run deliberately: I edited the code they cover, so passing my suites alone would not have shown whether the merge preserved their behaviour. Pushed with |
…n added Rebase fallout that git could not see: #14's new post_report test builds the sessions handlers, and those now require GitRepositoryLock and the source-control registry. Textually clean, semantically broken — the stubs get both, empty, since post_report reaching either would be a bug. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…-proven branch deletion (#17) * feat(git): add a per-repository lock for git worktree mutations Git allows one writer per repository: concurrent `git worktree remove` runs on one repo contend for .git/index.lock, and the losers block until the caller's own timeout kills them rather than queueing. This is one Effect Semaphore per repository root so callers can serialize the mutations that take that lock. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(contracts): branch cleanup, settle warnings, and git-hygiene errors settle_session gains `cleanupBranch` (delete a branch Phoenix did not create, against a merge proof), a `branchProof` on the worktree outcome, and a `warning` on the result for a settle whose child process outlived its stop. Two structured errors carry what prose cannot: which leg of the merge proof failed with the SHAs that disagree, and which git lock file is in the way plus the remedy. `ChangeRequest.headRefOid` is the merged commit the proof compares against; optional, since not every host reports one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(sessions): serialize worktree cleanup and prove branch merges before deleting Settling eight children at once with cleanupWorktree made every cleanup fail: concurrent `git worktree remove` calls fought over .git/index.lock until each timed out, and the one that ran alone was the only one that worked. A timed-out removal then left a zero-byte index.lock that blocked every later git command on the repository. - Worktree removal and branch deletion now run inside GitRepositoryLock, keyed by the project root, so parallel settles queue instead of racing. Sessions still settle independently of cleanup. - A git failure naming a lock file answers with SessionOrchestrationGitLockError: the path, its age, whether it matches the conservative stale heuristic (empty and older than 60s), and the remedy. Phoenix never deletes the lock itself — nothing in this process can prove no live git owns it. - cleanupBranch deletes a user's branch only when local head == remote head == the head commit of a merged PR. `git branch --merged` is useless here: this repo squash-merges, so a merged branch is never an ancestor of main. The proof runs before anything is destroyed, so a refusal costs nothing. - A settle whose stop wait times out no longer succeeds silently; it names the provider and the status the session was last seen in. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(sessions): worktree cleanup git hygiene What changed and why: one cleanup per repository at a time, a leftover lock reported rather than removed (with the heuristic and why Phoenix will not delete it), the squash-merge trap that makes `git branch --merged` the wrong proof, and the settle warning for a process that outlived its stop. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(sessions): make the lock error reachable, the lock key canonical, and the merge proof race-free Review findings on #17, all four of which were the difference between code that looks right and code that works. - The structured lock error could never fire in production: the driver threw away git's stderr and substituted a fixed detail string, so only a test's injected message ever matched. GitCommandError now carries a bounded stderrExcerpt (tail-first — the fatal line is last), and the driver attaches it to every non-zero exit. Proven end to end against a real held ref lock: real repo → real git failure → structured error naming the real lock file. - The semaphore was keyed on a trimmed path, so a symlinked alias or a linked worktree of the same repository got a *different* permit and serialized nothing. The key is now realpath + the repository's common git directory, with tests for both aliases (they fail against the old keying). - The merge proof was computed before the semaphore and acted on inside it, so a branch updated while a cleanup queued would be deleted on a stale proof. The two heads are re-read inside the critical section immediately before the delete; a branch that moved is kept and reported instead. - Lock matching keys on the .lock path rather than on git's English, and a branch delete that fails on a lock now raises the same structured error instead of swallowing it into a detail string — after clearing the thread's worktreePath, since the directory really is gone by then. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(vcs): redact credentials from git error excerpts, and delete proven branches by compare-and-swap Round-3 review on #17. - The stderr excerpt I added last round crossed RPC unredacted, and git echoes remote URLs that can carry their own credentials (https://x-access-token:ghs_...@host/o/r). Userinfo, query strings, fragments, and known token shapes are stripped before the excerpt is attached — and before it is truncated, so nothing survives on a boundary. Scheme, host, and path stay: which remote failed is the diagnostic value. This restores an invariant an existing driver test already asserted for args and raw stderr, and which the excerpt had quietly reversed. - The in-lock re-proof is only atomic against writers in this process; a terminal or a second Phoenix can still move the ref between the check and the delete. deleteRef now takes an expectedSha and routes through `git update-ref -d <ref> <old>`, so git itself refuses a moved ref under its own lock. Temporary t3code branches keep the plain force delete: no proof, nothing to hold git to. - A cleanup that removes the worktree but keeps the branch now reports a structured worktree.branchRefusal (branch, reason, SHAs, the expected commit) sharing its reason vocabulary with the BranchNotMerged error, instead of folding a partial success into a prose detail. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(sessions): refuse to delete a branch another worktree still has checked out Round-4 review on #17. The compare-and-swap closed the ref race but opened a hole: `git update-ref -d` is plumbing and, unlike `git branch -d`, deletes a branch another linked worktree is sitting on — leaving that worktree's HEAD pointing at nothing. Removing our own worktree first is not enough, because the other one can be a worktree a user made by hand that this process has never heard of and the repository lock does not cover. So inside the critical section, before the CAS, the worktree list is consulted and a branch still held anywhere is kept with a structured refusal naming the conflicting path. An unreadable worktree list fails closed for the same reason. The CAS stays as the final atomicity layer. The driver test proves the hazard rather than assuming it: `git branch -d` refuses a checked-out branch, plumbing deletes it. The settle test is the real regression the reviewer asked for — a real repository with a real second worktree, refused; the worktree removed; deleted. Also corrects a comment that claimed argv never carries credentials: clone, fetch, and push take the remote URL as an argument, which is exactly why redaction is written against URL shapes rather than a command allowlist. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(vcs): delete branches with porcelain, not compare-and-swap Design reversal, and the reasoning I gave last round was wrong. I argued the compare-and-swap bounded the worktree TOCTOU. It does not bound it at all: a checkout does not move the branch's OID, so `update-ref -d <ref> <sha>` succeeds while another worktree holds the branch and leaves that worktree's HEAD dangling. The two remaining races are not equally survivable, and that decides the mechanism. A checkout we get wrong corrupts someone else's directory and no reflog repairs it. A ref move we miss costs a ref pointer the reflog still holds. `git branch -D` refuses a checked-out branch atomically at delete time and cannot see a ref move; the compare-and-swap is exactly the reverse. So: - deleteRef is porcelain again, and the expectedSha parameter is removed rather than left lying around as an invitation to reintroduce the hazard. - The in-lock re-proof stays as the merged-safety check. The residual — an external ref move between re-proof and delete — is documented at the code that accepts it. - The worktree-conflict guard now wraps EVERY branch deletion, temporary t3code/* branches included; a user can check one of those out too. git enforces it regardless, so the guard's job is the structured refusal naming the directory, and git's own refusal is parsed back into the same shape for a worktree that appears in the gap. The driver test now pins the reason rather than the mechanism: the OID is unchanged by a checkout, porcelain refuses, plumbing with that very SHA deletes. Both real-worktree settle tests assert git was never asked, so the guard is load-bearing and not shadowed by git's own protection. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(sessions): give post_report's harness the services settle_session added Rebase fallout that git could not see: #14's new post_report test builds the sessions handlers, and those now require GitRepositoryLock and the source-control registry. Textually clean, semantically broken — the stubs get both, empty, since post_report reaching either would be a bug. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Why
A queued instruction can arrive after a child posted its terminal report. Until now the stale report stayed the record — and in the incident that motivated this, worse: the report omitted the late instruction, and the child then claimed compliance with something it had never done. A session needs a way to amend its own account.
What
post_reportgains an optionalsupersedesReportId.SessionOrchestrationInvalidInputErrormessage, so the denial cannot double as a probe for which report ids exist elsewhere. Checked before dispatch: a dangling link would be worse than a rejection, since no reader could follow it.supersedes_report_id, migration 048); the superseded report keeps its row, its event, and its body. The reverse link (supersededByReportId) is derived on read via a correlated subquery, so there is no "superseded" flag to drift out of sync, and the chain reads from either end.read_sessioncarry both ids. An amending report's parent notification leads withAMENDED report (supersedes …)before the summary, because a parent that already acted on the superseded report has to see that first.read_report— a superseded report still returns its body, plussupersededByReportIdand asupersededNoticesentence: a caller paging an old report cannot be relied on to notice a field it was not looking for.SPAWNED_SESSION_REPORT_INSTRUCTIONS(force-appended to spawned prompts) now states the rule: an instruction arriving after you reported means post an amending report, never claim retroactive compliance.Event-sourcing safety
supersedesReportIdis optional on both thethread.report.postcommand and thethread.report-postedpayload, so already-persisted report events replay unchanged (covered by a test that decodes a pre-feature payload).supersededByReportIdis deliberately not in the event: at post time no superseding report exists yet, so putting one there would make the event a lie.Tests
@effect/vitestit.effect, extending existing files:ProjectionRepositories.test.ts): A superseded by B, read from A and from B, list ordering, superseded body preserved.handlers.test.ts).handlers.test.ts,SessionSpawnReactor.test.ts).thread.report-postedpayload (orchestration.test.ts); decider pass-through (decider.reports.test.ts); migration shape and backfill (048_…test.ts).Verification (exit codes)
npm run typecheck— 0vp run -r test— 1, from one pre-existing unrelated failure:apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.test.ts > keeps grammar state across inline comment rows(a Shiki grammar-state assertion). Confirmed pre-existing by stashing this branch's changes and reproducing it on the clean tree. Everything else passed, includingapps/web58/58 andapps/server.vp lint --report-unused-disable-directives— 0 (8 warnings, all in files this PR does not touch)🤖 Generated with Claude Code
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.