Skip to content

Dispatch admitted mentions to Claude Code workers - #738

Closed
jorgemanrubia wants to merge 76 commits into
mainfrom
connect-dispatcher
Closed

jorgemanrubia wants to merge 76 commits into
mainfrom
connect-dispatcher

Conversation

@jorgemanrubia

@jorgemanrubia jorgemanrubia commented Sep 17, 2026

Copy link
Copy Markdown
Member

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 -p carries 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

  • The agent boundary. A driver opens or reloads a session in a working directory, with MCP servers each given an explicit environment. A prompt returns a stop reason, progress arrives as updates with no content in them, and a permission request is answered by policy. A launcher hook wraps the worker command, which is the seam the sandbox launcher will use. The Claude Code driver sets the policy with flags: an explicit permission mode, no host settings or MCP servers, and only the built-in tools the policy allows. It then checks the init message to confirm the mode is the one it asked for.
  • Tasks and attempts. An attempt is written as launching in the same transaction that exposes the originating event. running is 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.
  • The dispatcher. Concurrency, one task per working directory, the deadline, follow-ups delivered as further prompts in the same session, cancellation on shutdown, recovery of attempts a crashed process left behind, and the adopted-reply rule.
  • 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 reason shutdown. --shadow runs in an isolated state directory and dispatches nothing, and --project narrows 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 (claude by default) that the spawn driver runs.

Invariants

Ledger (the database enforces these where SQL can):

  1. Exposure comes before hand-off. launching and the originating event's exposed are written in one transaction, before the driver is asked to start anything. A follow-up is written exposed before its prompt is sent.
  2. Only one live task is allowed per conversation, per working directory and per event, and one live attempt per task.
  3. An ended task has no valid token.
  4. An exposure is withdrawn (event back to admitted) only when the driver proved no process existed, and only once. A second failure is blocked(spawn_failed). Every other exposed, unreported event is completed(unknown).
  5. Stop reason and outcome are never derived from each other, and settlement never overwrites a reported outcome.
  6. An adopted reply is only a link. It never makes an outcome succeeded.

Drivers and processes:

  1. Nothing is inherited. The worker and MCP environments are explicit allowlists — every name the MCP server may have is pinned to the connector's value or to nothing, so an agent's own value can never arrive in one the connector left unset — and no secret goes in argv. The task token reaches only the worker's MCP server, over a unix socket served to that worker's own process group (or a descendant of it), once per start of that server: an MCP host that restarts a stdio server re-runs it, so the bridge asks again, and between starts the socket does not accept at all. The bridge passes the token on over an inherited descriptor. It is in no environment, no argv and no file.
  2. The permission mode is set explicitly and verified. A session that can't confirm it is ended as unsafe.
  3. A refusal is recorded in the ledger at the moment the driver answers the permission request, once per tool call, and settled with its attempt — so a worker that exits before its result still keeps what it refused. A cancel the connector didn't ask for is never reported as cancelled.
  4. A worker is killed by the process group the connector started, never by name. A recorded group is signalled only while it is still provably that worker's — the identity is the pid AND the kernel's own start time for it, compared exactly, and it is established again before every signal that follows the first, because a pid freed during a grace period can be leading another group by the time the kill goes out.
  5. Updates carry no content, and every error, log line, status line and stderr tail that leaves a worker goes through one redaction function, which removes the task token, the values of the worker's own environments, paths under the state and runtime directories, emails and credential shapes. A worker's stderr is never passed on verbatim.
  6. A start error means one of two things and never both: ErrNotStarted alone is a worker that never existed and may start next time, so the connector retries it once; ErrUnusable is a configuration no retry can fix, so it does not.

Dispatcher:

  1. A record the dispatcher cannot start never fills the window ahead of one it can: the route connect.json approves now, the projects --project names, and a directory no live task holds are all part of the query.
  2. Stop reasons are the dispatcher's own record. Deadline and shutdown are stops it asked for; an unsafe session is failed; a session the driver reports ended, or a worker gone mid-turn, is lost; an exit the dispatcher caused is not a failure. Refusals are counted whatever ended the turn.
  3. Nothing reaches a worker in a directory connect.json no longer approves, including a follow-up on a task already running there.
  4. A worker a restart cannot identify or verify is never settled around: its attempt stays live and its conversation and directory stay held.

Proof

  • End to end, on a scratch project, pinned with --project. A mention on a card by the operator was answered by the agent 15 seconds later, in the worker's own words. complete_dispatch recorded succeeded with the reply id, and the attempt ended finished (the card).
    • A second mention two seconds after the first was queued and delivered into the same session.
    • SIGTERM with a worker running ended the attempt shutdown, left the event completed(unknown), exited 143 and left no worker process group.
    • After a SIGKILL of the connector, the restart ended the orphaned worker by its recorded group and settled the attempt lost.
  • The connector's prompt carries no content, and its worst case is inside the budget. A production-sized prompt measured 322 tokens with Claude's own tokenizer (the difference in input tokens between the prompt and a one-character baseline). The worst prompt the connector can write — the largest possible ids, the longest trigger, and a recording URL at the 120-character cap — is 449 tokens by an upper bound that assumes two characters a token, asserted under 500 by a test. A URL over the cap is left out of the prompt entirely rather than truncated; the worker reads the recording from get_dispatch.
  • A second end-to-end run on the current code, after the token bridge replaced the environment it used to travel in. A mention was answered by the agent about 20 seconds later; complete_dispatch recorded succeeded with the reply id and an acknowledgement; the attempt ended finished with the bridge's own process recorded on it; SIGTERM exited 143 and left an empty session directory (the card).
  • Real binaries took the token over the socket: Claude Code 2.1.266 and Codex 0.153.4, each through basecamp connect worker-mcp, with the handoff delivered, get_dispatch pulled, and no token in any file under the session or state directory.
  • Every rule was proven red. Each rule behind the invariants was reverted on the head and its test watched fail, then restored.

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; --project never 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 unsafe down one path and lost down 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. TokenHolder now 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 with HandoffUnaccounted, 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.StartedExact says where a start time came from, ProcessGone compares exact kernel identities and answers ErrIdentityUnknown — neither gone nor running — for a record that has none, and signalRecordedGroup is 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 of Worker.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 -1 is refused rather than silently ignored; a shadow run gets its own checkpoint lineage; and connect show names 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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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 connect with 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 run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to 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.

Comment thread internal/commands/connect_run.go Outdated
Comment thread internal/connector/dispatcher.go
Comment thread internal/connector/policy.go Outdated
Comment thread internal/commands/connect_run.go Outdated
Comment thread internal/commands/connect_run.go Outdated
Comment thread internal/connector/ledger_tasks.go Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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

Comment thread internal/connector/dispatcher.go Outdated
Comment thread internal/connector/dispatcher.go Outdated
Comment thread internal/commands/connect_run.go Outdated
Comment thread internal/connector/driver/claude/claude.go

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.Truncated when 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

Comment thread internal/connector/driver/worker.go
Comment thread internal/connector/policy.go
Comment thread internal/connector/dispatcher.go Outdated
Comment thread internal/connector/sdk_dispatch.go
Comment thread internal/commands/connect.go Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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, runConnect returns success. Treat any part that returns before cancellation as unexpected, synthesizing an error when err == 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 only Launched.Command.Dir. A sandbox launcher that returns its broker-owned scope through the documented field will therefore run in the original directory instead. Validate WorkDir and assign it to c.Dir before starting the process.

internal/connector/dispatcher.go:597

  • This emits an ended wire 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's launch.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

Comment thread internal/connector/dispatcher.go Outdated
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.
Base automatically changed from connect-mcp-domain to main September 18, 2026 06:48
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.
Copilot AI review requested due to automatic review settings September 18, 2026 07:18

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.go still describes connect as “Set up...” with only setup and show. basecamp commands and 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

Comment thread internal/commands/connect_run.go Outdated
Comment thread internal/connector/dispatcher.go
Comment thread internal/connector/driver/worker.go Outdated
Comment thread internal/connector/driver/worker.go
Comment thread internal/connector/policy.go Outdated
Comment thread internal/commands/connect_run.go
Comment thread internal/commands/connect_run.go Outdated
Comment thread internal/connector/dispatcher.go
Comment thread internal/connector/dispatcher.go Outdated
Comment thread internal/connector/setup/file.go
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.
Copilot AI review requested due to automatic review settings September 18, 2026 07:57

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 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 connect runs 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.Terminate revalidates 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 calling Process.Kill() on the saved PID.
    internal/connector/driver/worker.go:346
  • signalRecordedGroup rechecks the recorded PID/start-time before the SIGKILL, but this unconditional w.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; StartWorker always 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

Comment thread internal/commands/connect_worker_mcp.go
Comment thread internal/commands/connect_worker_mcp.go
Comment thread internal/connector/dispatcher.go
Comment thread internal/connector/driver/redact.go
@jorgemanrubia

Copy link
Copy Markdown
Member Author

🤖 Landed in #748, 6d0079ca — the six of us as one commit. Closing this as superseded, not abandoned: every commit here is in that merge.

Combining them is what found the defects none of us could see alone — a redispatch that blocked a conversation permanently once another branch's trigger landed beside it, a read-only ledger open that dropped the POSIX locks a writer held, doctor rejecting the configurations the same release adds, a recorded process losing its kernel start time so status called every live worker unverified, and four branches each writing their own migration 8.

@jorgemanrubia
jorgemanrubia deleted the connect-dispatcher branch September 18, 2026 10:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

commands CLI command implementations docs skills Agent skills tests Tests (unit and e2e)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants