Repository navigation
Conversation
The connector's state folder was <account>-<agent>, so its instance lock and ledger belonged to the agent: two profiles for the same agent on one computer could not each run a connector. The folder is now <account>-<agent>-<profile> (and the same under connect-shadow), so the lock and the ledger that holds the feed cursor are per profile. A second connector on the same profile is still refused. The old <account>-<agent> folder carries over by rename to the first profile that starts and proves it is that agent, under the old folder's lock: a connector an older build is still running there keeps it, and the start is refused as before. Adopting is idempotent; a profile that already has its own folder leaves the old one alone. Until a run adopts it, status, doctor, discard, release, import, shadow promote and guided setup's running check read the old folder. ResolveStateDir had no callers left since the worker's MCP server was removed (#815), and it accepted only the old name, so it goes.
There was a problem hiding this comment.
🟡 Changes recommended
The legacy-directory lock can be recreated during a rename race, bypassing serialization and potentially orphaning promoted state.
1 open finding
What changed in this PR
Keys connector state, locks, ledgers, and cursors by profile while migrating legacy agent-level state.
Changes:
- Adds profile-specific state directories with legacy adoption and fallback.
- Updates all connector state consumers and tests.
- Revises connector guidance for per-profile locking.
[!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 | Description |
|---|---|
skills/basecamp-connect/SKILL.md |
Documents per-profile connector limits. |
internal/connector/statedir.go |
Adds state naming and migration. |
internal/connector/statedir_test.go |
Tests naming, adoption, and locking. |
internal/connector/operator_migration_test.go |
Uses profile-specific fixture paths. |
internal/connector/lock.go |
Updates lock refusal wording. |
internal/connector/lock_test.go |
Removes obsolete agent-level rationale. |
internal/connector/ledger_dispatch.go |
Removes obsolete state-path resolver. |
internal/connector/ledger_dispatch_test.go |
Removes resolver tests. |
internal/commands/connect_start_wait_test.go |
Updates state helper calls. |
internal/commands/connect_setup_guided.go |
Checks running state per profile. |
internal/commands/connect_run.go |
Resolves, adopts, and locks profile state. |
internal/commands/connect_profile_state_test.go |
Adds profile and migration integration tests. |
internal/commands/connect_operator.go |
Resolves operator state per profile. |
internal/commands/connect_operator_test.go |
Updates operator tests. |
internal/commands/connect_doctor.go |
Checks the profile-specific ledger. |
🧠 Review effort: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…was taken A start can adopt the old <account>-<agent> folder between an operator command resolving it and taking its lock. Taking the lock then recreates an empty folder, and import would apply to a new, empty ledger and report success. The ledger is looked for again under the lock.
Discard, release, import and shadow promote resolved the old <account>-<agent> folder and acted on that path. A start adopting it in between left them holding a path that no longer named the ledger, and taking a lock there recreated an empty folder: promote could move the shadow ledger into it and orphan it. Each one now adopts the old folder for its profile first, under the old folder's lock, and acts on the profile's folder. While an older build's connector holds that lock, a decision acts on its ledger in place as before, and import and promote are refused as before. Reads (status, doctor, setup's running check) still read the old folder without moving it.
Pi's second pass found the writer adoption in the previous commit let an operator command move the agent's history into whichever profile ran it, with no proof that profile is the agent, and that the fallback beside a held lock could still open a stale path and create a fresh ledger there. Back to one owner rule: only a run that has proved it is the agent moves the old folder. Decisions act on the folder status reads, old or new: one that takes no lock opens the ledger without creating it, so a path a start moved away from under it fails instead of making a new ledger, and one that takes the lock looks for the ledger again under it. Shadow promote moves the shadow ledger into the profile's own folder, never into the old one, and refuses while the old folder still holds the connector's ledger, as it refuses any existing ledger.
Pi's third pass: promote looked only for a ledger in the old <account>-<agent> folder, so it went ahead beside an older connector that had taken its lock but not yet made its ledger, and read an old folder it could not inspect as one with no ledger. Promote now takes the old folder's lock for the whole promote when that folder exists, refuses while it is held, and refuses on anything but a clean "no ledger".
|
🤖 Review summary for head Which commit each reviewer read
Fixed
Declined, with reasons
For a person to decide: the plan asked for the cursor namespace to be keyed by profile. This PR keeps |


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
$XDG_STATE_HOME/basecamp/connect/<account>-<agent>-<profile>(same underconnect-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.lock_unavailable, "another connector already runs for this profile").<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: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.skills/basecamp-connect/SKILL.mdnow says one connector per profile.Every reader of the old
<account>-<agent>folderThe plan listed this as unverified. A search for every caller of
connectStateDir,connectStatePath,StateDirName,InstanceHolder,AcquireInstanceLock,StateRootandResolveStateDirturned up:basecamp connect(run,--shadow,--hold)connect_run.gorunConnectconnect status/status --shadow(ledger and lock holder)connect_operator.gorunConnectStatusconnect discard,release(openConnectLedger)connect_operator.goconnect importconnect_operator.goconnect shadow promoteconnect_operator.goconnect doctorledger checksconnect_doctor.goledgerChecksconnect setup"connector running" summaryconnect_setup_guided.goconnectorRunningconnector.ResolveStateDirledger_dispatch.gobasecamp mcp --connect-state.internal/mcpserverdoesn'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
checkpointstable, 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.TestARunAdoptsTheLegacyStateDirectoryWithItsCursorshows the adopted run resuming from the stored position.What it doesn't do
<account>-<agent>folder and runs beside the new one. That's a downgrade case, so no new guard was added.Tests
Run with a temporary
XDG_STATE_HOME:internal/connector/statedir_test.gointernal/commands/connect_profile_state_test.go, running the real command against the fake Basecamp:connect -P agentandconnect -P agent-b(same agent) run side by side, each with its own ledger and lock; a thirdconnect -P agentis refusedalso 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 shadowMutations, 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:
main's behaviour)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:
AdoptLegacyStateDiropenConnectLedgerReview record
openai-codex/gpt-6.1-sol:xhigh), local adversarial reads in the worktree. Each pass read a stated commit range:f78ef234f78ef234..5ad7d4faf78ef234..f84eacba8ead7320f84eacba..8ead7320Every commit on the branch was read by at least one pass.
f78ef234about 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 testpasses in full andmake lintreports 0 issues.