Say why one connector serves every repo, and drop references to redispatch

RO
17d ago
+396-164

Part of the end-to-end connector testing stack, but independent of the other PRs: it touches no test harness and depends on none of them.

Why

  • A second connector is refused without saying why it is never needed. People reach for a second basecamp connect to serve another repo. But the repo is not the connector's choice: the session reading its requests picks the repo for each project. The refusal named the holder and stopped there.
  • The skill did not say how to run many agents, or one agent over many repos. It said "only one connector per agent" and nothing about the shape that works.
  • Text still pointed at basecamp connect redispatch, which #815 removed with the worker side. The status line counted "redispatches waiting for their task", and admission's comments said a blocked no_route record "receives a holding reply". Nothing posts that reply now, and nothing re-runs that record.

What

The lock refusal (still lock_unavailable, exit 5):

Another connector for this agent is already running: its lock is held by pid 4242 since 2026-10-02T10:00:00Z
One connector serves every repo: the session reading its requests picks the repo for each project, so an agent never needs a second connector. Use the session already running it, or stop that connector before starting another.
  • The reason is in the hint, and the message names the holder as before.
  • A --shadow run has its own state directory and lock, so it never meets the connector's lock. A shadow refused here met another shadow, and it is told only that. Nothing implies that a shadow is a second connector.
  • connector.AlreadyRunningError carries the holder, so the command can word the refusal itself. Error() is unchanged for the other callers (discard, release, shadow promote).

skills/basecamp-connect/SKILL.md:

  • A new section, "One connector per agent, any number of repos". One agent with many repos uses one connector, and the session picks the repo for each project from repos in last.json. Many agents use one connector each, each in its own session, started from any folder.
  • last.json is shared by those sessions, so each write reads the file again first and keeps the repos already there.
  • "When the connector stops" now quotes the new refusal and says what to do.
  • The basecamp skill's paragraph about basecamp connect says the same in two sentences.

Redispatch and holding replies:

  • The intake sweep, LoadUndecided, the blocked-retry schedule (NextBlockedRetry), the reason docs in matrix.go, WithServed, the setup lock's doc in setup/private.go, and the ledger's decision comments now say what happens today. A blocked record that the timed retry leaves alone (untimed reasons such as no_route, or a window that has passed) stays blocked until a person discards it.
  • connect status keeps the redispatch_pending count, because the JSON field is part of the output. Only a build that ran workers wrote a pending redispatch or ended a task, so the text line now reads "redispatches left by an older build, which this one never runs".
  • No behavior changes.

Skill evals (skill-evals/cases/basecamp-connect/). The request line is given in the context, and the commands are mocked:

  • boost-retry-pre-send: every boost fails with Not authenticated for profile:. The boost is retried, three tries at most, with no login, and the handoff says the acknowledgement is still owed.
  • boost-no-retry-after-send: the boost fails after the request was sent (a deadline). One try only.
  • skip-handled-event: a restart prints an event_id that was already handled, next to a new one. Only the new one is boosted. Nothing touches the old one's recording or thread.
  • holding-reply-unmapped-repo: the project maps to no repo. The session boosts, then posts a holding reply as the agent at reply_to that mentions the requester, then asks which repo. It never guesses one.

Dead code found, not removed here

#815 left the ledger's worker-era write paths in place. No command reaches them:

  • Ledger.Redispatch (with RedispatchResult, LiveWorker, authorizeBlocked), and AuthorizedBlocked, which already said "Nothing calls it".
  • Ledger.LaunchTask and Ledger.EndAttempt, and with them the only code that ends a task.
  • setup.TryLock, which only tests call now.
  • Most of internal/connector/driver.

make deadcode reports the driver package and TryLock. It misses the ledger methods, because *Ledger escapes to an interface. Removing them touches the schema's triggers and many tests, so it should be its own PR. This one only makes the comments true.

Tests

  • TestASecondConnectorIsToldOneServesEveryRepo: both wordings, the holder, lock_unavailable and exit 5.
  • TestASecondConnectorIsRefusedBeforeItWaitsForAToken now also checks the holder and the hint end to end through the command.
  • TestSecondConnectorOnTheSameAgentIsRefused checks AlreadyRunningError.Holder.
  • The new eval cases compile under Onigmo (make check-eval-patterns) and pass the Go guard in connect_skilleval_test.go. The runner's own grade was given good and bad traces for each case: the good ones pass, and each bad one fails on the rule it breaks. The evals themselves were not run (they need an API key).
  • bin/ci is green.

Summary by cubic

Explains in the connector's lock refusal why an agent never needs a second connector, and rewrites the docs, comments, and skill text so they stop pointing at basecamp connect redispatch and holding replies, which went away with the worker side in #815.

  • A refused connector now gets a hint: one connector serves every repo, since the session picks the repo for each project. A shadow run gets its own phrasing, and the refusal still names the holder, carried by a new AlreadyRunningError.
  • Comments now say that a blocked record the timed retry leaves alone (untimed reasons such as no_route, or a window that passed) stays blocked until a person discards it; connect status's text line notes its redispatch count was left by older builds.
  • Adds four skill eval cases: boost retried before send, no retry after send, skipping an already-handled event, and a holding reply when a project maps to no repo.
  • Worker-era dead code (Ledger.Redispatch, LaunchTask, EndAttempt, setup.TryLock) is identified but not removed: dropping it touches schema triggers and many tests, so it stays for its own PR.