One connector per profile: key the state folder and lock by profile

Closed
RO
1d ago
+709-192

Step 5 of the plan on Follow-up: multiple connections per agent ("Plan (final)", step 5, "One connector per profile"). Branched off main; it doesn't depend on any other step.

What changes

  • The connector's state folder is now $XDG_STATE_HOME/basecamp/connect/<account>-<agent>-<profile> (same under connect-shadow). It used to be <account>-<agent>. The instance lock and the ledger live inside it, so both are per profile now, and so is the feed cursor, because it's stored in that ledger.
  • Two profiles for the same agent on one computer can each run a connector. Running a second connector on the same profile is still refused (lock_unavailable, "another connector already runs for this profile").
  • Migration. The old <account>-<agent> folder is renamed to the new one by the first profile that starts and proves it is that agent. This happens after the identity check, so a profile whose credential was rotated out never takes it. The rename happens while holding the old folder's lock:
    • If an older build's connector is still running there, the start is refused, as it is today.
    • Running it again does nothing. A profile that already has its own folder leaves the old one alone.
    • If two profiles for the agent exist at upgrade, the first one to start and verify takes the old folder. The other starts with an empty folder: it enters the feed at the present, so nothing is replayed.
  • Until a run adopts the old folder, status, doctor, setup's "running" check and the decisions (discard, release, import) all act on it where it is. No operator command moves it. Only a run that has verified it is the agent does.
  • The copy in skills/basecamp-connect/SKILL.md now says one connector per profile.

Every reader of the old <account>-<agent> folder

The plan listed this as unverified. A search for every caller of connectStateDir, connectStatePath, StateDirName, InstanceHolder, AcquireInstanceLock, StateRoot and ResolveStateDir turned up:

ReaderWhereNow
basecamp connect (run, --shadow, --hold)connect_run.go runConnectEarly lock check on the resolved folder (it creates nothing). Adopts, then locks, after the identity check.
connect status / status --shadow (ledger and lock holder)connect_operator.go runConnectStatusResolved folder
connect discard, release (openConnectLedger)connect_operator.goResolved folder, never moved. Without the lock, the ledger is opened without creating it. With the lock, it is looked for again while holding it.
connect importconnect_operator.goSame as above (it takes the lock)
connect shadow promoteconnect_operator.goThe shadow side is resolved. The destination is always the profile's own folder. When the old folder exists, promote holds its lock and refuses if that folder holds a ledger or can't be inspected. It used to build the old name by hand.
connect doctor ledger checksconnect_doctor.go ledgerChecksResolved folder
Guided connect setup "connector running" summaryconnect_setup_guided.go connectorRunningResolved folder, per profile
connector.ResolveStateDirledger_dispatch.goRemoved. It had no callers left: its only caller was the worker's MCP server, removed in #815 along with basecamp mcp --connect-state. internal/mcpserver doesn't read connector state at all. It also accepted only the old name.

Nothing else in this repo, or in the sibling agent repos on the author's machine, reads the folder.

A choice to check: the consumer namespace stays basecamp-connect-<agent>

The plan says to key the "cursor namespace" by profile. The cursor is now per profile because the ledger that stores it is per profile: the checkpoint store is that ledger's checkpoints table, and the server never sees the namespace. Renaming the namespace would have orphaned the adopted cursor. Intake would then log "no stored position: entering the feed at the present", which breaks "migration preserves the cursor", unless every checkpoint row were rewritten during adoption. Two profiles' connectors can never share a ledger: the same folder means the same lock, so the second one is refused. TestARunAdoptsTheLegacyStateDirectoryWithItsCursor shows the adopted run resuming from the stored position.

What it doesn't do

  • Old and new builds side by side for one agent. After adoption, an older build started for that agent makes a new <account>-<agent> folder and runs beside the new one. That's a downgrade case, so no new guard was added.
  • Mention caps across profiles. The per-24h agent-mention caps are counted in each ledger, so they now apply per profile rather than per agent. This follows from the plan's "two connections serving the same project both answer".

Tests

Run with a temporary XDG_STATE_HOME:

  • internal/connector/statedir_test.go

    • names are one per profile
    • the old folder moves with its contents and leaves no stray lock-holder file
    • adopting twice changes nothing
    • only one profile adopts
    • a profile that already has a folder is left alone
    • no old folder means nothing is created
    • adoption is refused while the old lock is held
    • two profiles hold their locks at once, and the same profile can only hold its lock once
  • internal/commands/connect_profile_state_test.go, running the real command against the fake Basecamp:

    • connect -P agent and connect -P agent-b (same agent) run side by side, each with its own ledger and lock; a third connect -P agent is refused
    • a run adopts the old folder and logs "resuming the feed from the stored position"; afterwards the ledger holds the old cursor and records
    • a start is refused while an older connector holds the old folder, and the old folder is left in place
    • status reads the old folder until a run adopts it; once adopted, no other profile sees it
    • a profile that can't prove it is the agent leaves the old folder alone
  • also in connect_profile_state_test.go: decisions act on the old ledger without moving it, including beside an older connector; import is refused beside one; promote moves an old shadow ledger into the profile; promote is refused while the old folder holds a ledger, is locked, or can't be inspected; promote is refused beside an older shadow

Mutations, each applied to a copy of the tree with test names parsed from the output. In the first round, 7 of 7 production changes are caught:

MutationCaught by
Folder name ignores the profile (main's behaviour)11 tests
The run never adoptsthe cursor test
Adoption skips the old lockthe connector refusal test
Readers skip the fallbackthe status test
Adoption before the identity check2 tests
The stale lock-holder file is keptthe move test
The early lock check creates the folder2 tests

All mutations were re-run against the head 8ead7320: 11 of 11 production changes are caught. That is the seven above, plus three for promote (promoting into the old layout, skipping the old-folder check, skipping the old lock) and one for decisions moving the old folder.

Four mutations survive, and they're labelled, not counted as covered:

  • Race-only guards (3). These can't be reached without injecting an interleaving:
    • re-checking the new folder after taking the old lock in AdoptLegacyStateDir
    • re-checking the ledger after taking the lock in openConnectLedger
    • opening the ledger without creating it in the lock-free decision path
  • Second site (1). In promote, treating a ledger-inspection error as "no ledger". The guarantee rests on the lock taken just before it, which already refuses a folder that can't be inspected.

Review record

  • Pi (openai-codex/gpt-6.1-sol:xhigh), local adversarial reads in the worktree. Each pass read a stated commit range:
PassReadFoundOutcome
1f78ef234(a) stale old-folder path in operator commandsfixed
(b) an older build started after adoption runs beside the new onedeclined, see "What it doesn't do"
(c) a retry after a crash between rename and directory sync skips the syncdeclined: the folder moves as a unit, so a lost rename leaves the whole ledger under the old name, where the next start adopts it again. Nothing is lost or replayed.
Keeping the namespacePi checked it and found no problem
2f78ef234..5ad7d4fa(a) operator commands adopting without verifying the profilefixed by removing writer adoption
(b) a lock-free decision could create a ledger at a stale pathfixed
3f78ef234..f84eacbatwo promote findings: an older connector holding the old lock before its ledger exists, and an old folder that can't be inspectedfixed in 8ead7320
4f84eacba..8ead7320no findings

Every commit on the branch was read by at least one pass.

  • Copilot. One finding on f78ef234 about the lock living inside the folder being renamed. The promote-orphaning and stale-writer parts are fixed. The stable-migration-lock part is declined, with the reasons given in the thread.

make test passes in full and make lint reports 0 issues.