feat(sessions): support explicit spawned-session checkouts - #4
Conversation
roughcoder
left a comment
There was a problem hiding this comment.
Review: feat(sessions): support explicit spawned-session checkouts
Verdict: REQUEST-CHANGES — the feature itself (ref resolution, validation, injection safety) is in good shape. What blocks it is that both response paths now fail closed on git errors, which turns a best-effort metadata addition into a new way for read_session and spawn_session to break outright.
Reviewed by reading only — per orchestration resource limits I did not run tests, typecheck, or any live spawn.
Blocking
1. [blocker] read_session now hard-fails whenever the child's worktree is unreadable — apps/server/src/mcp/toolkits/sessions/handlers.ts:474-479
read_session used to be a pure snapshot read. It now runs localStatus + resolveCommit against child.worktreePath and propagates the error via Effect.mapError(operationError(...)).
If the worktree directory is gone (user removed it, cleanup after merge, disk moved) git rev-parse is spawned with a non-existent cwd and fails unconditionally — localStatus's non-repo fallback at GitWorkflowService.ts:271-278 does not save you, because resolveCommit in the same Effect.all has no such fallback. The result: the parent can never retrieve the child's report or messages again. That's the core orchestration loop, broken by an optional metadata field. Same failure for a worktree at a repo with no commits yet (rev-parse HEAD fails).
These four fields are already Schema.optional on ReadSessionResult (sessionOrchestration.ts:148-151) — the contract is explicitly built to tolerate their absence. Please make it best effort: .pipe(Effect.catch(() => Effect.succeed(null))), matching the existing precedent at handlers.ts:324-326.
2. [major] spawn_session can report failure for a session that was actually created — handlers.ts:400-405
Same pattern, worse consequence. requireShell(threadId) has already returned, so the child thread exists and is running. If the checkout resolution then fails, the caller gets an error and never receives threadId — an orphaned live session the parent cannot read, message, or stop. Rollback doesn't help here: cleanupCreatedThread is scoped to bootstrapTurnStart (ThreadTurnBootstrap.ts:389-396) and has long since completed successfully. Make it best-effort and fall back to spawned.branch / sha: null / dirty: null (which the result schema already permits).
Non-blocking
3. [major] gitRef/checkoutPr still pay for — and can be killed by — the startFromOrigin base resolution — ThreadTurnBootstrap.ts:293-316
When startFromOrigin is set, the code fetches origin and calls resolveRemoteTrackingCommit({refName: baseBranch}), then discards worktreeBaseRef at line 316 the moment checkoutRef/checkoutPr is present. So an explicit checkout request does a pointless fetch, and — the real problem — fails outright if the caller's baseRef has no remote-tracking branch, even though baseRef is documented as merge-base metadata only. Skip the startFromOrigin block entirely when checkoutRef/checkoutPr is set.
4. [major] Fetch failures are reported as "ref does not exist" — ThreadTurnBootstrap.ts:350-355
The Effect.mapError wraps the whole fetchRemote → resolveCommit chain, so a network outage, auth prompt, or proxy failure on git fetch origin is reported as Git ref "X" does not exist locally or on origin. That will send agents chasing a typo'd ref name instead of a broken remote. Distinguish the fetch failure from the post-fetch resolve failure.
5. [minor] baseRef's documented meaning doesn't match its behaviour — packages/contracts/src/sessionOrchestration.ts:52-54
The comment says "Base ref recorded for the spawned branch's merge-base metadata." But with gitRef and checkoutPr both absent, checkoutRef falls back to worktreeBaseRef (= baseRef) at ThreadTurnBootstrap.ts:316, so baseRef alone also decides what gets checked out. An agent reading only the tool schema will not expect that. Since this text is the MCP tool description an LLM acts on, please state both roles.
6. [minor] read_session does a forced git status on every poll — handlers.ts:470-479
invalidateLocalStatus deliberately busts the cache, so every poll pays a full git status + rev-parse on the child's worktree. read_session is the polling endpoint in this API; on a large repo with several children being watched this is a recurring cost for data that rarely changes between polls. Either drop the invalidation and accept cached status, or gate the refresh behind an opt-in parameter.
7. [minor] checkoutPr is GitHub-shaped with no signal to the caller — GitVcsDriverCore.ts:2819-2833
refs/pull/<n>/head doesn't exist on GitLab (refs/merge-requests/<n>/head) or Bitbucket. On those remotes users get a raw git fetch failure. Fine to defer, but worth a clearer error or a doc note.
8. [nit] No --end-of-options; refs aren't charset-validated — sessionOrchestration.ts:51-57
I checked this specifically and command injection is not a concern: everything goes through ChildProcess.make("git", args) with an argv array (GitVcsDriverCore.ts:731-741), no shell. checkoutPr is Schema.Int.check(isGreaterThan(0)) before it reaches the refs/pull/${n}/head template, so the PR ref is clean. Path traversal via branchName is also blocked — createWorktree sanitizes / and git rejects .. as a branch name.
What remains is ordinary option injection: gitRef/branchName are TrimmedNonEmptyString, so a leading - reaches git rev-parse --verify <ref>^{commit} and git worktree add -b <branch> as an option-looking argument. Worst case is a confusing git error, not code execution. Adding --end-of-options or a ref-name regex would close it cheaply.
Things I checked that are correct
- Silent project-root fallback is now an explicit error ✅ —
validateSpawnCheckoutInputruns athandlers.ts:329, before the fallback at 333-341, and returnsSessionOrchestrationInvalidInputError. Exactly what the brief asked for. - Schema safety ✅ — every new input field is
Schema.optional, so additive and backward compatible. No typeless/empty structs.Schema.Intis already exercised in this same tool bymessageLimit(sessionOrchestration.ts:128), so no new MCP-schema risk. - Rollback on ref-resolution failure ✅ — all new failures occur before
createWorktree, andcatchCause→cleanupCreatedThread(ThreadTurnBootstrap.ts:389-396) covers plainErrorfailures. No half-created worktrees from the new paths, andtoBootstrapDispatchCommandCauseErrorpreserves the custom messages viaerror instanceof Error ? error.message(line 58-62). refName: checkoutCommit.commitSha✅ — resolving to a SHA beforeworktree addneatly avoids passing user ref text to that command.- Detached-HEAD projects —
repoBranch !== nullis false on detached HEAD, so checkout fields are rejected there. The error message ("...not a git repository with a current branch") does cover it, so this reads intentional; noting it in case it wasn't.
Test quality
validateSpawnCheckoutInput tests assert real behaviour on a genuinely pure function — good call extracting it. The contracts tests assert schema shape only, which is the right scope for schemas.
The gap: the riskiest code in the PR has no tests at all. ThreadTurnBootstrap.ts:316-362 — the local-resolve → origin-fetch → re-resolve fallback, the no-origin branch, the PR-head path, and gitRef overriding startFromOrigin — is entirely uncovered, and findings #3 and #4 both live there. Also uncovered: SpawnSessionInput's inverse cases (SpawnSessionResult requires sha/dirty while ReadSessionResult makes them optional — inconsistent, though harmless since the handler always supplies them), and the missing-worktree path behind #1/#2. Given this toolkit had zero tests before, this is real progress and I wouldn't gate on coverage — but a fake GitWorkflowService exercising the ref-resolution branches would have caught #3.
|
Addressed in b365c82e2:
Deferred follow-ups: |
roughcoder
left a comment
There was a problem hiding this comment.
Delta re-review — b365c82e2
Verdict: APPROVE-WITH-NITS. All eight findings are genuinely resolved, and the fixes are the right shape rather than symptom patches. One mechanical issue in the new test files will almost certainly fail npm run typecheck — worth fixing before merge, but it doesn't touch production code.
Delta reviewed only (4aed46090..b365c82e2). No tests, typecheck, or lint run.
Findings status
| # | Was | Status |
|---|---|---|
| 1 | read_session hard-fails on dead worktree |
✅ Resolved |
| 2 | spawn_session orphans a live session |
✅ Resolved |
| 3 | startFromOrigin kills explicit checkouts |
✅ Resolved |
| 4 | Fetch failure reported as "ref does not exist" | ✅ Resolved |
| 5 | baseRef doc mismatch |
✅ Resolved |
| 6 | Forced git status on every poll |
✅ Resolved |
| 7 | GitHub-only PR refs | ⏭️ Deferred, accepted |
| 8 | No --end-of-options |
✅ Resolved (main path) |
#1/#2 — the important ones. resolveSessionCheckout (handlers.ts:89-93) is exactly right, and I specifically checked that the catch isn't over-broad: Effect.catch handles only the typed error channel, so it swallows GitManagerServiceError/GitCommandError and leaves defects and interrupts propagating. The declared channels on localStatus/resolveCommit are git-only, so nothing non-git is being hidden. The read_session spread also correctly moved from checkout ? to worktreePath ? with ?. fallbacks (handlers.ts:489-495), so a dead worktree now yields sha: null, dirty: null while still returning branch/worktreePath — and ReadSessionResult's Schema.optional(Schema.NullOr(...)) accepts that. Report and message access can no longer be blocked. Same on the spawn side against SpawnSessionResult's NullOr fields.
#4 — verified the pipe structure, not just the strings. Effect.mapError on fetchRemote is applied before Effect.andThen, and the post-fetch resolveCommit carries its own mapError (ThreadTurnBootstrap.ts:88-108). So the two failure modes genuinely can't bleed into each other — a real fix, not just reworded text.
#3 — !hasExplicitCheckout && also short-circuits the now-pointless remoteExists call. Good.
#8 — --end-of-options lands on the rev-parse path, which was the exposed one. git worktree add -b <branchName> still takes branchName positionally, but -b consumes its value verbatim, so a leading-dash name just becomes an invalid ref name git rejects. Fine to leave.
Must fix before merge
[major] The two new test files will fail tsgo --noEmit. apps/server/tsconfig.json includes src, so *.test.ts is typechecked under strict: true. Vitest transpiles without typechecking, which is why vp test run passes and this stays invisible until the root typecheck:
handlers.test.ts:40— thelocalStatusstub returns{ refName, hasWorkingTreeChanges }, butVcsStatusLocalResult(packages/contracts/src/git.ts:202-219) also requiresisRepo,hasPrimaryRemote,isDefaultRef, andworkingTree. Four missing required properties.handlers.test.ts:41,ThreadTurnBootstrap.test.ts:12,14—Effect.fail(new Error(...))yieldsE = Error, but these methods declareE = GitCommandError, which is aSchema.TaggedErrorClass(git.ts:330) and therefore nominal.Erroris not assignable to it.
Both stubs are typed against the exact service method types via Pick<...>, so there's no structural escape hatch. The repo's own convention for partial stubs is an as unknown as cast (e.g. ActivityPayloadProjection.test.ts:14) — applying that, or constructing a real GitCommandError, fixes it. This is precisely the category the PR flagged as unverifiable, so it's worth a targeted typecheck rather than a full one.
Nits
ThreadTurnBootstrap.test.ts:20-26,50-56—expect(error.cause).toMatchObject({ error: expect.objectContaining({...}) })couples the assertion toCause's internal shape.Cause.squash/Cause.failureswould be more durable. (I couldn't run it; if it passes today, it's fragility rather than a defect.)sessionOrchestration.ts:145-146— the comment still says these fields are "resolved live". Now that #6 removed the forced invalidation,branch/dirtycome from the cached status whileshastays live. Worth softening the wording.handlers.ts:93— the swallowed git error isn't logged. A silently-nullshais hard to debug; anEffect.logDebugin the catch would cost nothing.- Test coverage —
resolveSessionCheckout's test asserts the null result, which is real behaviour rather than a smoke test. Neither test covers the actual regression end-to-end (thatread_sessionstill returns report/messages when the worktree is gone), nor that non-git defects still propagate. Fine given the starting point.
Merge note
apps/server/src/mcp/toolkits/sessions/handlers.test.ts is still an add/add conflict with PR #5, which creates the same file with an @effect/vitest import. Whichever merges second needs a manual merge.
Constraint: Keep checkout creation inside the existing bootstrap rollback boundary.\nRejected: Ad-hoc shell commands in the MCP handler | GitWorkflowService already exposes the Git driver seam.\nConfidence: high\nScope-risk: narrow\nDirective: Worktree cleanup and reclamation remain a separate lifecycle concern.\nTested: vp test run packages/contracts/src/sessionOrchestration.test.ts apps/server/src/mcp/toolkits/sessions/handlers.test.ts; npm run typecheck.\nNot-tested: Live provider/session spawn against a remote pull request.
Constraint: Session reports and controls must survive missing or unreadable worktrees.\nRejected: Making checkout metadata a required read/spawn prerequisite | optional fields must not orphan live sessions.\nConfidence: high\nScope-risk: narrow\nDirective: PR-ref portability beyond GitHub remains follow-up work.\nTested: vp test run apps/server/src/mcp/toolkits/sessions/handlers.test.ts apps/server/src/orchestration/ThreadTurnBootstrap.test.ts packages/contracts/src/sessionOrchestration.test.ts; npm run typecheck.\nNot-tested: Live remote fetch and provider-session spawn.
Constraint: Tests must match nominal Git workflow error and status contracts.\nRejected: Untyped Error and partial result stubs | root tsgo rejects the incompatible channels.\nConfidence: high\nScope-risk: narrow\nDirective: Keep optional checkout enrichment non-blocking.\nTested: npm run typecheck.\nNot-tested: Focused Vitest suite not rerun; this change is test typing and assertions only.
Constraint: Effect code must use platform services and tagged failure values.\nRejected: Node path import and generic Error failures | root tsgo treats them as errors.\nConfidence: high\nScope-risk: narrow\nDirective: Preserve the fetch-versus-missing-ref distinction through typed errors.\nTested: npm run typecheck; vp run --last-details (all 15 tasks succeeded, exit 0).\nNot-tested: Focused Vitest suite not rerun.
Constraint: Bootstrap now resolves a commit before creating a worktree.\nRejected: Leaving the driver mock implicit | seam tests must exercise the production dependency path.\nConfidence: high\nScope-risk: narrow\nDirective: Keep revision stubs transparent so base-ref assertions remain meaningful.\nTested: vp test run apps/server/src/server.test.ts -t five bootstrap seam cases; vp test run apps/server/src/mcp/toolkits/sessions/handlers.test.ts apps/server/src/orchestration/ThreadTurnBootstrap.test.ts packages/contracts/src/sessionOrchestration.test.ts; npm run typecheck (exit 0).\nNot-tested: Full t3 server suite.
a8c515d to
35550cb
Compare
…ult test Pre-existing on main, surfaced by rebasing onto it: #4's ReadSessionResult decode fixture does not supply the stoppedBy / stopRequestedAt / stopReason / interruptedToolCall / lastCompletedOperation fields that #6 made required, so `packages/contracts/src/sessionOrchestration.test.ts` fails at origin/main with "SchemaError: Missing key at [stoppedBy]". Verified by running the test against main's own unmodified sessionOrchestration.ts. Not caused by this branch, but it would land on top of it, so it is fixed here rather than left for the merge.
…e_session with worktree cleanup (#10) * feat(orchestration): synthesize terminal reports and add settle_session A spawned session that died without calling post_report left its parent with nothing to act on: a `stopped` child produced no notification at all, and an errored one produced a bare error message with no record of what it had done. Separately, nothing in the server ever reclaimed a spawned worktree, so a long orchestration run leaked a directory per child. Synthetic terminal reports: when a spawned child's session reaches `stopped` or `error` with no report posted, SessionSpawnReactor dispatches an ordinary `thread.report.post` carrying the termination reason, the last tool activity and assistant message, and an explicit "work is likely unfinished" warning. Everything downstream — projection, the report card, the parent wake-up — behaves as it does for an agent-posted report, which is how `stopped` now notifies the parent at all. `SessionReport.origin` ("agent" | "system", migration 043) keeps a synthesized report from ever reading as the child's own claim, in the parent's notification and in the UI badge. settle_session: the parent explicitly settles a child rather than sessions settling themselves. It refuses a starting/running child with an actionable message instead of stopping it, and keeps the decider's guards for open approvals and queued turns. `cleanupWorktree: true` permanently deletes the child's worktree and its temporary `t3code/…` branch — refused, with the specific dirty files and unpushed commit count, unless the work is committed and pushed or `force: true` is passed. Branches Phoenix did not create are kept and reported. The result always names what was removed and what was kept, and read_session now exposes the child's worktree path so a deleted worktree stops being advertised. Validation: `npm run typecheck` clean; new handler and reactor tests plus the decider report tests pass. * fix(orchestration): stop a live session on settle, mark reports only once posted Addresses code review on PR #10. [major] The terminal-report dedup set was marked before the dispatch that posts the report. processEventSafely swallows dispatch failures, and a terminated session emits no further status transition, so a transient failure branded the episode "handled" with nothing persisted and the parent was never told — worse than the old behaviour, which at least never pretended. The episode is now marked only once the report is actually posted, the persisted `reports` check remains the real duplicate guard, and the dispatch retries twice before giving up, since this is the last chance to reach the parent. [major] settle_session now stops a still-alive session instead of leaving a `ready` provider process running behind a settled thread. A turn in flight (starting/running) is still refused — interrupting live work stays a deliberate stop_session — but an idle-but-alive child is stopped as part of settling, because settling is the parent declaring it finished. Order is stop -> settle -> assess -> remove, which also closes the window the reviewer flagged: the dirty/unpushed check now runs after the process is gone, so nothing can write to the worktree between the check and the removal. If the session does not reach "stopped" within the timeout the thread is still settled but cleanup is withheld and the caller told. [minor] Documented that deleteRef is a trusted-caller primitive: the branch-safety decision lives in decideBranchCleanup, not in the driver. Tests: both majors were in wiring that only pure-helper tests covered. Added SessionSpawnReactor.wiring.test.ts driving the real reactor against a stub engine (verified failing against the old ordering: 3 dispatch attempts instead of 6), and settleSession.test.ts driving the real handler against stub services, asserting the stop/settle/inspect/delete call order. Validation: `npm run typecheck` exit code 0; 34 tests across the five touched files pass; `vp lint` clean. * fix(contracts): supply required stop-audit fields in the checkout result test Pre-existing on main, surfaced by rebasing onto it: #4's ReadSessionResult decode fixture does not supply the stoppedBy / stopRequestedAt / stopReason / interruptedToolCall / lastCompletedOperation fields that #6 made required, so `packages/contracts/src/sessionOrchestration.test.ts` fails at origin/main with "SchemaError: Missing key at [stoppedBy]". Verified by running the test against main's own unmodified sessionOrchestration.ts. Not caused by this branch, but it would land on top of it, so it is fixed here rather than left for the merge.
#5/#6/#9/#10 Rebased onto main after #4/#5/#6/#9/#10 merged; carries the reconciliation: - Envelope delivery applies to agent AND system-origin synthetic reports (same report-posted path); formatReportMessage keeps #10's origin-aware lead and delivers >1KB summaries as an envelope with a read_report hint. - SessionReportEnvelope carries #9's structured data compactly: recommendation and completionPercent whole, findings/validation as counts; and #10's origin so a synthesized epitaph is visible at a glance. read_report returns the full findings/validation arrays with every page (bounded by the 32KB structured cap) plus origin. - findByReportId now selects abstract, structured_json, and origin and maps through the shared mapReportRow, so no column can be silently dropped by one read path; the ProjectionSnapshotQuery Struct.pick allowlist gained abstract. - Migration renumbered 043 -> 046 (043 structured, 044 stop audit, 045 origin landed first). - read_report docs record UTF-16 code-unit paging (pages can run one unit short or long at surrogate boundaries) and the unguessable-UUID timing assumption behind the reportId-only lookup path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… sibling access (#8) Rebased onto main after #4/#5/#6/#9/#10 merged; carries the reconciliation: - Envelope delivery applies to agent AND system-origin synthetic reports (same report-posted path); formatReportMessage keeps #10's origin-aware lead and delivers >1KB summaries as an envelope with a read_report hint. - SessionReportEnvelope carries #9's structured data compactly: recommendation and completionPercent whole, findings/validation as counts; and #10's origin so a synthesized epitaph is visible at a glance. read_report returns the full findings/validation arrays with every page (bounded by the 32KB structured cap) plus origin. - findByReportId now selects abstract, structured_json, and origin and maps through the shared mapReportRow, so no column can be silently dropped by one read path; the ProjectionSnapshotQuery Struct.pick allowlist gained abstract. - Migration renumbered 043 -> 046 (043 structured, 044 stop audit, 045 origin landed first). - read_report docs record UTF-16 code-unit paging (pages can run one unit short or long at surrogate boundaries) and the unguessable-UUID timing assumption behind the reportId-only lookup path. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
What
spawn_session, with early invalid-input checks.Why
Review sessions can now be pinned to the same PR head or git revision, so parents and reviewers can verify the revision inspected.
Validation
vp test run packages/contracts/src/sessionOrchestration.test.ts apps/server/src/mcp/toolkits/sessions/handlers.test.tsnpm run typecheckGaps
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.