Dispatch admitted mentions to Claude Code workers - #738
jorgemanrubia wants to merge 76 commits into
Conversation
e0cda3d to
9428454
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Recovery, route authorization, worktree handling, platform gating, and path containment have unresolved correctness and security issues.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Adds end-to-end dispatch of admitted connector mentions to Claude Code workers, building on the MCP dispatch interface from PR #736.
Changes:
- Adds the driver boundary, Claude Code adapter, permission policy, and process lifecycle.
- Adds task/attempt persistence, recovery, follow-ups, deadlines, and settlement.
- Activates
basecamp connectwith worker configuration, shadow mode, and project filtering.
[!TIP]
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or rungh pr ready --undo.
Click "Ready for review" or rungh pr readyto reengage.
File summaries
| File | Description |
|---|---|
.surface |
Records the new CLI flags. |
STYLE.md |
Documents bare connect behavior. |
scripts/check-bare-groups.sh |
Allows the bare connect command. |
internal/connector/setup/file.go |
Adds worker configuration. |
internal/connector/setup/file_test.go |
Tests worker validation and defaults. |
internal/connector/setup/apply.go |
Applies worker configuration changes. |
internal/connector/sdk_dispatch.go |
Lists memberships and adoptable replies. |
internal/connector/policy.go |
Defines connector permissions. |
internal/connector/policy_test.go |
Tests policy and prompt filtering. |
internal/connector/ledger.go |
Registers the task migration and hooks. |
internal/connector/ledger_tasks.go |
Implements tasks, attempts, settlement, and adoption. |
internal/connector/ledger_tasks_test.go |
Tests ledger lifecycle invariants. |
internal/connector/ledger_admission.go |
Runs verdict hooks transactionally. |
internal/connector/driver/worker.go |
Manages Unix worker processes. |
internal/connector/driver/worker_unix.go |
Implements process-group signaling. |
internal/connector/driver/worker_other.go |
Rejects unsupported worker platforms. |
internal/connector/driver/spawn/spawn.go |
Selects spawn drivers by worker. |
internal/connector/driver/spawn/spawn_test.go |
Checks worker/driver parity. |
internal/connector/driver/proctime_other.go |
Handles unsupported Unix process timestamps. |
internal/connector/driver/proctime_linux.go |
Reads Linux process start times. |
internal/connector/driver/proctime_darwin.go |
Reads macOS process start times. |
internal/connector/driver/env.go |
Builds allowlisted environments and redacts logs. |
internal/connector/driver/driver.go |
Defines the agent driver API. |
internal/connector/driver/driver_test.go |
Tests environments and process termination. |
internal/connector/driver/claude/claude.go |
Implements Claude Code sessions. |
internal/connector/driver/claude/claude_test.go |
Tests Claude protocol and safety invariants. |
internal/connector/dispatcher.go |
Dispatches and supervises tasks. |
internal/connector/dispatcher_test.go |
Tests dispatcher lifecycle behavior. |
internal/commands/connect.go |
Makes connect runnable and adds worker setup. |
internal/commands/connect_run.go |
Wires intake, admission, and dispatch. |
internal/commands/connect_run_test.go |
Tests project parsing and state paths. |
Review details
- Files reviewed: 31/31 changed files
- Comments generated: 6
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
9428454 to
7866973
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Crash recovery can leave an unrecorded worker active, and dispatch scope, route revocation, and refusal updates have unresolved correctness gaps.
Get a fresh assessment by requesting another Copilot review.
Review details
- Files reviewed: 31/31 changed files
- Comments generated: 4
- Review effort level: Balanced
7866973 to
b5efc24
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Recovery can release directories while descendant workers remain active, and additional permission, concurrency, and reply-adoption correctness issues remain.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (1)
internal/connector/sdk_dispatch.go:57
- The SDK exposes
result.Meta.Truncatedwhen this limit leaves pages unread. Without checking it, a busy Campfire can have one matching agent line in the returned 500 and a second after the acknowledgement outside the scan, causing a false adoption despite the exactly-one requirement. Treat truncation as an inconclusive scan.
result, err := r.Client.Campfires().ListLines(ctx, recordingID, &basecamp.CampfireLineListOptions{
Limit: AdoptionScanLimit, Sort: "created_at", Direction: "desc",
})
- Files reviewed: 31/31 changed files
- Comments generated: 5
- Review effort level: Balanced
There was a problem hiding this comment.
🟡 Changes recommended
Worker lifecycle and configuration edge cases can leave untracked processes or report successful termination incorrectly.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (4)
Previously missed (2) — in code that hasn't changed since the last review.
internal/commands/connect_run.go:300
- If a long-running part terminates cleanly while the context is still active (for example, the feed closes with a nil terminal result), this records no
firstErr; after canceling the other parts,runConnectreturns success. Treat any part that returns before cancellation as unexpected, synthesizing an error whenerr == nil, so supervisors restart a connector that stopped doing its job.
internal/connector/driver/worker.go:55 - The launcher contract returns the directory in
Launched.WorkDir, but this discards it and executes using onlyLaunched.Command.Dir. A sandbox launcher that returns its broker-owned scope through the documented field will therefore run in the original directory instead. ValidateWorkDirand assign it toc.Dirbefore starting the process.
internal/connector/dispatcher.go:597
- This emits an
endedwire transition even when all settlement retries failed and the ledger still has a live attempt. NDJSON consumers then observe a state that never committed. Emit the ended line only in the successful settlement branch.
d.line(DispatchLine{Type: "dispatch", TaskID: launch.TaskID, AttemptID: launch.AttemptID, State: string(AttemptEnded), StopReason: string(end.Stop)})
internal/connector/dispatcher.go:553
- The MCP subprocess re-resolves
-P <profile>from configuration under the worker'slaunch.WorkDir. Profiles may validly come from trusted repo/local config (internal/config/config.go:131-145,332-366), so a connector started where such a profile exists can launch a routed worker elsewhere where the profile is missing or replaced; the MCP server then fails before serving the dispatch. Pass an immutable snapshot of the effective profile/account/base URL to the worker MCP instead of resolving a cwd-dependent profile again.
MCPServers: []driver.MCPServer{{
Name: MCPServerName,
Command: d.opts.MCP.Command,
Args: []string{"mcp", "--profile", d.opts.MCP.Profile, "--connect-state", d.opts.MCP.StateDir},
Env: serverEnv,
- Files reviewed: 33/33 changed files
- Comments generated: 1
- Review effort level: Balanced
A worker the connector starts pulls its instruction through MCP rather than having content pasted into its prompt. basecamp mcp --connect-state <dir>, with the task token in BASECAMP_CONNECT_TASK_TOKEN, serves get_dispatch, ack_dispatch and complete_dispatch from the connector's ledger, bound to that one task. The ledger gains the tasks and task_events tables the three actions need: a token hash that a redispatch supersedes, and each event's delivery state, admitted to exposed to delivered to completed, which never goes back.
A retried or concurrent launch could put one event on two live tasks and hand it to two workers. The database now refuses a second live task for an event (task_events_one_live_task), task creation dispatches its records in the same transaction, and superseding a task retires its events. createTask runs in a caller's transaction for the dispatcher. get_dispatch serves only a dispatched or completed record with its content, and its earliest skips any other, so one withdrawn or blocked event no longer hides the task. A worker's report is recorded whatever the record did since; only a dispatched record is completed by it. The worker's server opens the ledger without creating or migrating it, keeps the connect domain under --domains, refuses --read-only before touching the token, refuses a token for no live task at startup, compares accounts as numbers, and keeps ledger failures out of the transcript. Mention stripping walks the markup the way the SDK's reader does.
createTask refuses a record whose snapshot was dropped, which a supersede and a bookkeeping move back to admitted could otherwise dispatch as a task whose worker is told nothing is waiting. An id named twice for one task gets its own error rather than reading as another task's event.
OpenExistingLedger checked the file existed and then ran the owner's privacy check, which creates a missing file, and SQLite's default open, which does too. A ledger removed in between left a worker holding a new empty one. The existing-ledger path now uses setup.CheckPrivateFile, which inspects without creating, and opens SQLite with mode=rw.
Superseding a task left its never-exposed events dispatched on no live task. It now returns them to admitted, as the spec's settlement rule says, and leaves exposed ones dispatched for their settlement or a redispatch; it also runs inside a caller's transaction for a redispatch. get_dispatch no longer serves a record completed before this worker was exposed to it.
Three classes of finding, fixed where each belongs. A conversation has one task at a time: createTask refuses an event whose conversation has a dispatched record outside the task being created, so the siblings a supersede returns wait behind the event a worker still holds rather than starting tasks of their own. The mention stripper is no longer a second parser by accident. It is the SDK reader's walk, rule for rule, and the two are held together by a differential test over hostile markup and a fuzz target: no mention of the agent survives, no one else's is lost, text without one is untouched, and every span removed is one the reader reads as the agent's mention in place. An explicit get and the earliest now share one servable rule. A state directory is accepted in one place, connector.ResolveStateDir: it must be the canonical directory under the connector's state root and carry this account's number, so a ledger copied elsewhere and renamed is refused. The task token is taken out of the environment before authentication, which can start helper processes.
…and waited for The Opus round on 8483da8 found the fallback directory was litter nothing swept, in a base with none of the checks a session directory gets. It now lives under one short directory per connector (ShortSocketBase, in the per-user runtime directory or /tmp, through the same private-path check the state and session directories get), which a start sweeps, so a crash leaves nothing behind. Also from that round: the socket's directory is named to the launcher (SessionConfig.SocketDir) and to the task's redaction, so a sandbox launcher can let a worker reach it and no log prints its path; the release point waits for a handoff in flight before it reads who took the token, and TokenSocket's result can be read by more than one caller; an unsafe permission mode keeps a log line of its own; and doctor's check says it answers for this shell's environment, names a short path that exists on this platform, and has a test of its own.
…wered one question twice now answer it once An MCP host that restarts a stdio server re-runs its command, and a pipe is read once, so a socket that served one handoff left a restarted server with no Basecamp tools and no way to say so. The socket now serves one handoff per start — a fresh accept, the same peer checks, its own window — up to MaxTokenHandoffs, and anything but a delivery ends it. The connector follows every handoff (OnHandoff), so the newest server is the process the release point ends. Copilot's round on af7e411, four findings, each a place two paths answered one question differently: - capacity: dispatchReady counted down from a snapshot while release could hold an attempt. Both now read Dispatcher.free(). - worker identity: the recorded start time was the clock's while OwnsWorker compares the kernel's. Both now read the kernel's. - an unverified session: a result before init ended unsafe while a closed output ended lost. Both now end ErrSessionUnverified. - adoption's boundary: the next acknowledgement was the task's while settlement had already moved the conversation to another task. Both now read the conversation's. And from the Opus round: the short socket base is chosen so what MkdirTemp makes under it still fits, with /tmp still the escape hatch a deep runtime directory needs; the MarkRunning failure path settles the socket before reading the taker, like every other release; SocketDir reaches a launcher through Scope; Redactor.Lines is the one line rule (Stderr is its last line), and Worker.StderrLines is how a driver reads a refusal its agent wrote before the noise that buries it; the worker's MCP server environment pins every name it may have, so an agent's own value can never arrive in one the connector left unset.
… which is what a restart is Opus r8 on the multi-handoff socket: a fresh window after every delivery left the token there for the asking for the rest of it — an agent's own tools run in the worker's group, so the rule that says only the worker may have it was buying less than it says — while the case the change exists for, a server that dies twenty minutes into a task, was still not served. Both are the same question: the socket arms for the NEXT start of the worker's MCP server, and the next start is that server ending. It now waits for the recorded taker to be gone (driver.ProcessGone) before it accepts again, unbounded in time and bounded by MaxTokenHandoffs, and falls back to one more window only where that process's identity could not be read. driver.ProcessGone is now the one answer to "is this still that process?": OwnsWorker asks it and adds the group, which is what a worker's leader needs and a worker's MCP server does not — the group is the agent's and outlives its servers. Also from r8: a terminal handoff after a delivery is how every healthy attempt ends, so it is logged at debug and the warning is kept for a worker that never took its token at all; the taker's group is checked against the trust rule on the second kernel read too, not only the peer's; the one-use language is gone from eight doc comments that had outlived it, mcp.json's comment no longer claims to hold a task token, and start no longer returns a bool nothing reads. And card 19's accounting, through the coordinator: a refusal with no tool call id counts every time it happens, identical text included — only an id can say two refusals are one.
Card 23 measured both ACP adapters: each re-runs its MCP server's command on a death, so the per-start handoff is the right shape, and both shapes pass the peer check (claude-agent-acp restarts inside the worker's group, codex-acp in a group of its own as a descendant of the leader). What they cannot do is tell anyone when a restarted server came up without a token: no adapter reports it on the wire. So when the budget is spent the socket says so (HandoffSpent) and the connector logs it against the attempt, which is the only place it can be seen.
…five The recorder deduplicates nothing: it records what it is told, once per call, and deciding what is one refusal belongs to the driver that read it — a tool call id where the agent gives one, and where a driver reads refusals from lines of output, the line and its occurrence in that output, so two identical lines are two refusals and reading the same output twice records neither again (card 19's Codex accounting). A test holds the recorder to it. And the budget's reasoning, since it was a decision and not a default: the socket arms again only once the server holding the token is gone, so the rate is already the rate at which that server dies. Five is about when an attempt's socket ENDS — a server that has restarted five times in one task will not settle down, and every moment the socket is armed is a moment the agent's own tools could ask for the token instead.
…time Opus r9, and one of its findings was a real miscount: a result carrying several permission denials with no tool call id collapsed them all into one, because the guard compared an empty id against an empty id. Only an id can say two refusals are one, so the guard now runs only where there is one — three nameless denials are three refusals, with a case for it. The rest is the token socket saying what it did: - a peer that is not the worker's ends the socket for good, so it is a warning whether or not a delivery came first; so is a window that ran out after a delivery, which leaves a restarted server with no tools; only a socket the release point closed is quiet. reportHandoff is one function with one test. - a write that fails after the peer passed its checks is not a refusal and does not end the socket (a host that kills its server between the connect and the read): HandoffUndelivered, and the next start is still owed its token. - a delivery the connector cannot attribute clears the taker rather than leaving the last one standing, so the socket never waits on — or ends — a process that is not the one holding the token. - the wait for the holder to be gone backs off to 15s, gives up after ten kernel errors rather than waiting forever on a question nothing can answer, and the boot time it reads is now read once rather than per poll. - the handoff lines go through the task's own redaction, not the dispatcher's. And three doc claims that had outlived the code: a wait with no deadline described as running out, a taker described as unrecorded when it is on the attempt and a restart ends it by that record, and a list of OwnsWorker's callers that named commands this card does not have.
#736's tip makes the pull the hand-off: ack_dispatch and complete_dispatch now require the event's own get_dispatch, and the trigger says so. Three of this card's ledger tests acknowledged or completed straight after Dispatch, which a worker never does, so they pull first — the same shape the live e2e has always taken.
0fc79b0 to
caab8f1
Compare
736 squash-merged, so main carries as one commit what this branch still has as 33. Every conflict but one was a file this branch never touched beyond 736's head; those take main's, which also carries the descriptor sealing and the conversation identity that went on before the squash. The one that mattered: this branch's tasks-and-attempts migration was numbered 6, and main took 6 for the acknowledgement trigger in 747. It is migration 7 here. A ledger that has applied 6 would never see a renumbered one, so the order main established is the order that stands.
736 moved mcp_token_unix.go to mcp_token_linux.go, gating the token handover to Linux. The merge added main's new file without removing this branch's old one, so both declared readTaskToken.
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved platform, token-handoff, and process-identity issues can break dispatch or weaken worker isolation.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (1)
internal/commands/connect.go:36
- The command catalog in
internal/commands/commands.gostill describesconnectas “Set up...” with onlysetupandshow.basecamp commandsand catalog-driven help therefore omit the new foreground run and retain stale wording; update the catalog alongside this command change.
Short: "Run a local agent connector for a Basecamp agent",
- Files reviewed: 53/53 changed files
- Comments generated: 11
- Review effort level: Balanced
A pid is not an identity, and neither is a wall-clock stamp taken around a fork: the comparison accepted anything within three seconds of the recorded time, which under fast pid reuse is wide enough for a stranger to pass as the worker. Process now carries whether its start time came from the kernel. ProcessGone compares exact kernel identities, and answers ErrIdentityUnknown — neither gone nor running — for a record that has none, so OwnsWorker refuses, nothing is signaled and nothing of that attempt is settled or released. The ledger writes a start time only where the kernel gave one, so what a restart reads back is exact by construction. Group identity was established once, before the grace period, and every signal after it went out by the saved negative pgid however long the wait had been. signalRecordedGroup is now the one place a recorded group is signaled and it establishes ownership every time: the recorded process is alive and is still that worker, or the worker led the group, no live process holds its pid any more, and members remain — which can only be the worker's own children, since a group id cannot change hands while anything is still in the group. A pid that is alive and is not the recorded process is the case this exists for, and gets nothing. TerminateRecorded's later SIGKILL, ConfirmGroupGone's and all three of Worker.Terminate's go through it.
… for The socket used to treat two different things as a holder that had let go. A delivery whose recipient could not be identified was recorded as the zero taker — the same value as no delivery at all — so it armed for another handoff and told the release point nothing was out. And after ten unreadable answers about whether the recorded holder had exited, it armed again on the assumption that a process it could not see had gone. Either way a second descendant could be given a task token the first may still be holding, and the attempt could be settled and its working directory released around it. There is now one rule for a holder that cannot be accounted for, and both paths reach it: TokenHolder carries the state the zero Process could not express, seeing the holder exit is the only thing that arms the socket again, and anything else ends the socket with HandoffUnaccounted, logs it, and makes the release point hold the attempt instead of settling it. The ledger records it too, so a restart reads a missing taker as a token still out rather than as a token nobody took.
…side EvalSymlinks answers ENOENT to two opposite questions: a component that is not there, and a symlink that is there pointing at something that is not. The policy read both as a name yet to be created and placed it inside the working directory, so a write to <workdir>/link was approved while opening it would create /elsewhere/missing. The walk now asks Lstat for each component and follows a link that exists to wherever it points, existing or not, with a hop budget so a loop resolves to nothing and is refused.
…ption on one it does Recovery read and settled a previous process's attempts on the run context, so a signal arriving during it could end a worker and leave its record live — cleanup interrupted by the thing that should only stop new dispatch. It runs on an uncancelled context now, as every other settlement does; only the working directories' own reconciliation, which settles nothing, stays on the caller's. Adoption had the opposite problem. It is on the wait group Run waits on at shutdown, and it was given the settlement's uncancellable context with a two-minute budget, so a slow reply listing could hold a SIGINT for the whole of it — the comment saying nothing waits on adoption was no longer true. Shutdown now cancels the listing and waits only for it to notice; a link already found is still written.
macOS passed the platform check and then failed every non-shadow dispatch at the worker's MCP handshake. The token reaches the worker's server on an inherited descriptor, and `basecamp mcp --connect-token-fd` accepts that hand-over only where the descriptors a process inherited are sealed against everything it starts: Linux, which #736 gated it to deliberately, because a token read off a descriptor nothing sealed is a token every hook that ran before the command could have. Reading process start times, the other thing the connector needs, macOS can do — but one of two is not support, so the command says so at the start rather than at the far end of each task.
Two things this command hands intake were wrong about it. Intake takes only a positive --since as an override, so --since -1 was accepted and then quietly ignored: the run resumed from wherever the ledger had got to while the person who typed it believed they had moved the position. It is refused now, before the account is read or the feed is touched, and zero is still the default that means resume. And a shadow run shared the connector's checkpoint lineage while having its own state directory, ledger, lock and checkpoint — against intake's contract that two connectors in one account never share one. A shadow beside the connector it watches is two, so it gets a namespace of its own; the connector's own is unchanged, so nothing already running re-enters the feed.
The workers line was formatted from the driver alone, so the coding agent connect.json records was invisible — and a file written before the field existed showed nothing where the default it still means should be.
…ntext context.AfterFunc on a context of the dispatcher's own says the right thing but asks contextcheck to follow a context that is not the caller's, and the answer to a linter at a boundary like this is not a nolint. A channel closed once on the way out says the same thing plainly.
…closed The release point closes the token socket and waits for it to be finished with, so that a handoff in flight is not still deciding while the attempt is released — and then went ahead anyway on a warning when the wait ran out. A delivery that may still be crossing is a token that may reach a process this attempt never records, which is the same thing as a holder that cannot be accounted for, so it is now held by the same rule.
There was a problem hiding this comment.
🔵 Needs a closer look
The security-sensitive worker and token lifecycle still has unresolved correctness concerns requiring human review.
Review details
Suppressed comments (3)
skills/basecamp/SKILL.md:1465
- The skill says
basecamp connectruns on macOS, but the command accepts only Linux (internal/commands/connect_run.go:415-416). Update this platform statement so macOS users are not directed to a command that immediately fails.
internal/connector/driver/worker.go:346 Worker.Terminaterevalidates the recorded process group above, but this PID-only fallback can still kill an unrelated process after the worker exits and its PID is reused during the grace period. Keep the kill group-based and revalidate ownership for any retry; if the group cannot be safely signalled, let the release-point confirmation hold the attempt instead of callingProcess.Kill()on the saved PID.
internal/connector/driver/worker.go:346signalRecordedGrouprechecks the recorded PID/start-time before the SIGKILL, but this unconditionalw.cmd.Process.Kill()sends SIGKILL by numeric PID without that ownership check. If the leader exits and the kernel reuses its PID during the grace period, the validated group signal is skipped but this call can terminate an unrelated process. Remove the direct PID kill;StartWorkeralways makes the worker the group leader, so the revalidated group signal is the safe termination path.
- Files reviewed: 54/54 changed files
- Comments generated: 4
- Review effort level: Balanced
|
🤖 Landed in #748, Combining them is what found the defects none of us could see alone — a |
Stacked on Serve a connector worker its dispatch over MCP. Do not merge before it.
The connector can take a mention into its ledger and admit it, but nothing turns an admitted mention into a working agent. Nobody starts a worker, keeps it to a deadline, gives it follow-ups or records how it ended. So there is no end-to-end path yet from a mention on a card to a reply by the agent.
Originally tracked in 18 Dispatcher, tasks and attempts, and the Claude Code spawn driver.
Why
This is the first driver. Spawning
claude -pcarries no protocol risk, and it proves intake, ledger and lifecycle end to end. Codex, the ACP driver and the lifecycle outbox stack on this PR, so the agent boundary follows ACP v1's session model from the start. That makes ACP one more driver rather than a rewrite.What changes
launchingin the same transaction that exposes the originating event.runningis written with the process and session. Ending an attempt supersedes the token, settles every event and ends the task, all in one transaction. Stop reasons and outcomes are stored separately.basecamp connect -P <agent>: the run command. It wires the instance lock, the ledger, intake, admission and the dispatcher. Stdout carries pointer lines, stderr carries logs, and a signal exits 130 or 143 after live attempts are cancelled with stop reasonshutdown.--shadowruns in an isolated state directory and dispatches nothing, and--projectnarrows the feed. It runs on Linux only: the task token reaches the worker's MCP server on an inherited descriptor, and that hand-over is accepted only where the descriptors a process inherited are sealed against everything it starts, which is Linux alone (Serve a connector worker its dispatch over MCP #736). connect.json names the worker (claudeby default) that the spawn driver runs.Invariants
Ledger (the database enforces these where SQL can):
launchingand the originating event'sexposedare written in one transaction, before the driver is asked to start anything. A follow-up is writtenexposedbefore its prompt is sent.admitted) only when the driver proved no process existed, and only once. A second failure isblocked(spawn_failed). Every other exposed, unreported event iscompleted(unknown).succeeded.Drivers and processes:
ErrNotStartedalone is a worker that never existed and may start next time, so the connector retries it once;ErrUnusableis a configuration no retry can fix, so it does not.Dispatcher:
--projectnames, and a directory no live task holds are all part of the query.Proof
--project. A mention on a card by the operator was answered by the agent 15 seconds later, in the worker's own words.complete_dispatchrecordedsucceededwith the reply id, and the attempt endedfinished(the card).shutdown, left the eventcompleted(unknown), exited 143 and left no worker process group.lost.get_dispatch.complete_dispatchrecordedsucceededwith the reply id and an acknowledgement; the attempt endedfinishedwith the bridge's own process recorded on it; SIGTERM exited 143 and left an empty session directory (the card).basecamp connect worker-mcp, with the handoff delivered,get_dispatchpulled, and no token in any file under the session or state directory.Reviews
Nine rounds of adversarial review by a separate Opus agent (labelled as such, not a human review), plus Copilot on each head. The blocking ones: a backlog the dispatcher could not start starving everything behind it;
--projectnever reaching the dispatcher; and a Windows build broken by the redaction change. Everything raised is fixed here with a test that fails without the fix, except what belongs to later cards: the lifecycle outbox (20), redispatch and status (21), and worktrees (19), which this PR refuses rather than ignores.Three rounds ended in the same shape, so it is worth naming: a finding that keeps coming back is usually two code paths answering one question differently. Capacity was a snapshot in one place and a live count in another; a worker's start time was the clock in one place and the kernel in another; a session that could not be vouched for ended
unsafedown one path andlostdown another; adoption's boundary was the task's in the query and the conversation's in the rule; and "is this still that process?" was answered twice over. Each is now one function both paths call.Copilot reviewed the head again once this PR retargeted
main, and its eleven findings fell into the same shape twice more, so they are answered as two rules rather than six patches.What an unverifiable token holder means. A delivery whose recipient could not be identified was recorded as the same zero taker as no delivery at all, and running out of tries to see whether the recorded holder had exited was treated as seeing it exit. Both armed the socket for another handoff and told the release point nothing was out, so a second descendant could get a token the first may still hold and the attempt could be settled and its directory released around it.
TokenHoldernow carries the state the zero process could not express; seeing the holder exit is the only thing that arms the socket again; everything else ends the socket withHandoffUnaccounted, says so, and holds the attempt. The paths that now share it: the socket's wait between handoffs (waitForTakerGone), the record of a delivery with no identity (handed), the release point's second confirmation (confirmTakerGone), the release point's wait for a handoff still in flight (settledTaker), and recovery after a restart, which reads the state off the attempt rather than inferring it from a missing taker.What an unverifiable process identity means. The start time was the kernel's where it could be read, but the comparison accepted anything within three seconds of it, and a group's identity was established once, before the grace period, while every later signal went out by the saved negative pgid.
Process.StartedExactsays where a start time came from,ProcessGonecompares exact kernel identities and answersErrIdentityUnknown— neither gone nor running — for a record that has none, andsignalRecordedGroupis the one place a recorded group is signalled and establishes ownership every time. The paths that now share it:TerminateRecorded's later SIGKILL,ConfirmGroupGone's, all three ofWorker.Terminate's, and the ledger, which writes a start time only where the kernel gave one so what a restart reads back is exact by construction.The rest: a dangling symlink inside the working directory is followed to where it points before the policy decides it is inside (opening it creates the file it points at, which can be anywhere); recovery settles on a context a shutdown does not cancel, while adoption's reads are stopped by one;
--since -1is refused rather than silently ignored; a shadow run gets its own checkpoint lineage; andconnect shownames the worker the driver runs. One finding was answered by restricting the connector rather than extending it: macOS passed the platform check and then failed every non-shadow dispatch at the MCP handshake, so the command says Linux at the start rather than at the far end of each task.One more of the same kind, which Copilot did not name: the release point closed the socket, waited for it to finish deciding, and then went ahead on a warning when the wait ran out. A handoff still in flight is a token that may reach a process the attempt never records, so it is held by the same rule.