Skip to content

Let a person see, authorize, close and hold what the connector runs - #741

Closed
jorgemanrubia wants to merge 50 commits into
connect-outboxfrom
connect-operator-commands
Closed

jorgemanrubia wants to merge 50 commits into
connect-outboxfrom
connect-operator-commands

Conversation

@jorgemanrubia

@jorgemanrubia jorgemanrubia commented Sep 17, 2026

Copy link
Copy Markdown
Member

Stacked on Post the connector's own lifecycle messages exactly once, or not at all and must not merge before it.

The connector can run an agent's work, but a person has no way to see what it did or to decide what it will not decide for itself. An event whose worker may have acted (completed(unknown)) or failed waits for a redispatch nobody can issue; a blocked record cannot be retried or closed; and the cutover from the Ruby connector needs a shadow ledger promoted, and old entries reconciled, without a moment in which the new connector could dispatch something twice.

Originally tracked in status, doctor, redispatch, discard, the hold marker and migration commands. Spec: "Commands", "Human decisions on records", "Hold and migration" in the Connector spec.

What changes:

  • basecamp connect status reads the ledger read-only, without the instance lock, beside a running connector: hold, feed position (held or not, never the token), last poll-served id, gaps and losses, queue depths, live tasks, lifecycle messages waiting for a person, held records, the last 20 dispatches with outcomes. doctor checks token, identity, ticket mint, feed poll, the ledger, the worker binary and an MCP handshake with the agent's server, started as a worker's is.
  • redispatch <event_id> and discard <event_id> are a person's decisions, recorded with who made them. --hold on the run command sets a durable hold marker; release clears it. shadow promote and import <file> do the cutover.
  • A new record state, held: where a record tagged for review would have waited for a worker.

Invariants

Held by the database where SQL can say it (triggers in the new migration), each with a test that was watched going red with the rule reverted:

  1. Nothing a hold tagged for review reaches a worker without a person. Any write of admitted or queued onto a tagged, unauthorized record lands as held, whatever path wrote it — admission, a task's end returning a sibling, a withdrawal. The verdict reports held, so no guard acknowledgement is called for, and one already pending is canceled.
  2. While the hold marker stands, no attempt row is written, no task takes a follow-up and no outbox intent moves to sending. The marker lives in the ledger, so every start respects it until release; held records stay held on release.
  3. A hold is one transaction: the marker, a new intake generation, the review tag on every non-terminal record of earlier generations, waiting records held, and any authorization from before the hold — a redispatch still waiting for its task included — withdrawn.
  4. A terminal record leaves its state only against a decision row made after its outcome settled: completed to admitted by the redispatch the record names, which the move consumes; completed(unknown) to discarded(by_operator) by a discard of that record. Discarded never leaves. The database cannot tell a person from anything else holding the file; what it enforces is that the authorization exists, is this record's, and postdates what it overrides.
  5. A redispatch never runs two workers for one event. The replaced task's token is superseded in the authorization's transaction, its recorded worker is terminated by process group, a superseded task takes no further follow-ups, and an event whose task is still live becomes admitted only in the transaction that ends that task.
  6. Redispatch admits only a record that still has its snapshot and route (queued when its conversation is live); a blocked record keeps its state, runs its prerequisite again through admission — revision-guarded, so it is safe beside a running connector — and is admitted when that succeeds. Refused for succeeded, discarded, and anything live.
  7. shadow promote takes both instance locks, commits the hold in the shadow ledger, folds it into one file and renames it into place: a crash at any point leaves the untouched shadow or a held ledger, and running it again finishes (tested by SIGKILLing a helper process at each step, including a shadow already held by --hold). import is one transaction: tombstones only for entries decided done, everything else tagged with its state and blocking reason kept, and a file with an entry it cannot apply changes nothing.
  8. Nothing asks a person for a decision they have already made: a discard or an import cancels the lifecycle messages of a record it closes, and a completion notice is rendered again from the records when the outbox claims it.
  9. Reading is not deciding. Status opens the ledger read-only, creates nothing, takes no lock, and shows no content, feed position or token. A decision that would migrate a ledger older than the build takes the instance lock first, so it never changes the schema under a running connector.

One enforcement point

Redispatch has no lifecycle path of its own. Every state change it makes goes through the ledger's single state-changing write, and the edges out of a terminal record exist only against a decision row the database checks: the record must name the redispatch decision, it must be that record's, it must postdate the outcome it overrides, and the move consumes it. The replaced task's token is superseded in the same transaction, and the record is admitted only when that task ends. What redispatch adds on top is the authorization itself, which is the part the spec makes a person's.

Not here

  • Retained worktrees in status wait on card 19's worktree ledger, which is a sibling of the PR below this one rather than under it. The seam is in place (connector.WorktreeLister, and status says "not tracked by this build" while it is nil); it is wired to the real rows — path, task, state and reason, size, and the line that says an orphan's record and branch are left as they are — in the rebase onto main after card 19 lands.
  • The blocked-record recovery schedule does not exist yet; when it does it should run the records a person authorized (Ledger.AuthorizedBlocked); today redispatch runs the prerequisite itself and says so when it could not.
  • Terminating the replaced worker still goes through the driver's recorded-process check; it moves to the shared ownership API the PR below is landing.

@github-actions github-actions Bot added commands CLI command implementations tests Tests (unit and e2e) docs labels Sep 17, 2026
@jorgemanrubia
jorgemanrubia requested a balanced review from Copilot September 17, 2026 06:46

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

Production outbox wiring is missing, terminal decision invariants are bypassable, and doctor can approve an unsupported driver.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Adds operator visibility and control for connector execution, including durable holds, record decisions, migration tooling, lifecycle messaging, and worker selection.

Changes:

  • Adds status, doctor, redispatch, discard, release, shadow promotion, and import commands.
  • Introduces held records, operator decisions, lifecycle outbox processing, and migration safeguards.
  • Adds Claude worker dispatch infrastructure and extensive invariant tests.

[!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
STYLE.md Documents the bare connect command exception.
scripts/check-bare-groups.sh Allows the runnable connect command group.
internal/connector/setup/file.go Adds worker configuration and validation.
internal/connector/setup/file_test.go Tests worker defaults and validation.
internal/connector/setup/apply.go Applies worker configuration changes.
internal/connector/sdk_dispatch.go Adds SDK reply and membership adapters.
internal/connector/promote.go Implements atomic shadow-ledger promotion.
internal/connector/policy_test.go Tests worker permission and prompt policy.
internal/connector/outbox.go Defines lifecycle-message storage and resolution.
internal/connector/outbox_run.go Implements sending and reconciliation.
internal/connector/outbox_basecamp.go Implements Basecamp message posting/listing.
internal/connector/operator_status_test.go Tests read-only, secret-free status.
internal/connector/operator_migration_test.go Tests promotion/import crash safety.
internal/connector/lock.go Exposes diagnostic lock-holder information.
internal/connector/lifecycle.go Renders and records lifecycle messages.
internal/connector/ledger.go Registers held state and new migrations.
internal/connector/ledger_tasks.go Prevents task selection during holds.
internal/connector/ledger_tasks_test.go Expands task lifecycle coverage.
internal/connector/ledger_import.go Implements reconciliation imports.
internal/connector/ledger_hold.go Implements holds, generations, and decision invariants.
internal/connector/ledger_events.go Adds operator-only state transitions.
internal/connector/ledger_decisions.go Implements redispatch and discard decisions.
internal/connector/ledger_admission.go Reports database-enforced held verdicts.
internal/connector/driver/spawn/spawn.go Selects spawn drivers by worker.
internal/connector/driver/spawn/spawn_test.go Verifies worker-driver coverage.
internal/connector/driver/driver_test.go Tests process isolation and termination.
internal/connector/driver/claude/claude_test.go Tests Claude session safety and lifecycle.
internal/connector/dispatcher.go Adds workspace recovery and safer prompts.
internal/connector/admission/matrix.go Adds the held admission state.
internal/connector/admission/commit.go Accepts database-produced held results.
internal/connector/admission/commit_test.go Updates admission state coverage.
internal/commands/connect.go Registers runnable and operator commands.
internal/commands/connect_run.go Implements the foreground connector runtime.
internal/commands/connect_run_test.go Tests run flags and state paths.
internal/commands/connect_process_unix.go Detects live processes on Unix.
internal/commands/connect_process_other.go Supplies the non-Unix fallback.
internal/commands/connect_operator_test.go Tests operator command workflows.
internal/commands/connect_doctor.go Adds connector readiness diagnostics.
internal/commands/connect_doctor_mcp_unix.go Performs Unix MCP handshake checks.
internal/commands/connect_doctor_mcp_other.go Skips MCP checks off Unix.
e2e/smoke/smoke_lifecycle.bats Records operator commands as smoke-test exclusions.
.surface Updates the generated CLI surface.
Review details
  • Files reviewed: 46/46 changed files
  • Comments generated: 3
  • 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
Comment thread internal/connector/ledger_hold.go
Comment thread internal/commands/connect_doctor.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

Outbox recovery, exhaustive reply listing, promotion recovery, and platform-specific tests have unresolved correctness issues.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (4)

Previously missed (4) — in code that hasn't changed since the last review.

internal/connector/outbox_run.go:145

  • Recovery cannot safely reconcile every sending intent immediately. A previous process may have been killed just after committing the claim while its HTTP request is still in flight; listing at once can find zero matches and permanently mark the intent indeterminate before the request lands. Apply ReconcileAfter during startup as well so every request gets the documented landing window.
    internal/connector/outbox_run.go:326
  • A failed destination listing leaves the intent eligible for reconciliation on every outbox tick, so an outage causes one request per second forever. This also never reaches the promised terminal indeterminate outcome for an unlistable destination. Persist a bounded retry count/next-at time, back off failed listings, and settle after the configured attempt/deadline limit.
    internal/connector/promote.go:120
  • Promotion is not crash-idempotent when the shadow was already started with --hold. SetHold intentionally preserves the existing marker's cause, so the moved ledger still has cause hold; after a crash following the rename, promoted rejects it because it requires shadow_promote, even though this transaction recorded a shadow-promotion decision. Ensure promotion leaves an idempotence marker that promoted recognizes (for example, validate the recorded shadow_promote decision rather than only the preserved hold cause).
    internal/connector/sdk_dispatch.go:25
  • The implementation does not satisfy this exhaustive-listing contract: comments ignore Meta.Truncated, and Campfire reads only one response even though the adjacent BasecampPoster.List implementation explicitly checks comment truncation and paginates chat lines. A busy destination can therefore hide a second reply and cause AdoptableReply to adopt the visible one as unique. Return an error for truncated comments and page Campfire until reaching since, as the poster does.
  • Files reviewed: 57/57 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread internal/connector/operator_migration_test.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.

🔵 Needs a closer look

The cross-cutting database invariants, crash recovery, process control, and stacked dependency warrant final human review.

Review details
  • Files reviewed: 27/27 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

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

Lock-based migration safety, process identity verification, and empty permission locations have unresolved correctness and security issues.

Get a fresh assessment by requesting another Copilot review.

Review details
  • Files reviewed: 61/61 changed files
  • Comments generated: 3
  • Review effort level: Balanced

Comment thread internal/commands/connect_operator.go Outdated
Comment thread internal/connector/driver/worker.go
Comment thread internal/connector/policy.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

Blocked redispatches can still produce stale prompts, connection status remains inaccurate, and doctor does not exercise the worker MCP mode.

Get a fresh assessment by requesting another Copilot review.

Review details
  • Files reviewed: 33/33 changed files
  • Comments generated: 4
  • Review effort level: Balanced

Comment thread internal/connector/lifecycle.go
Comment thread internal/connector/outbox_run.go Outdated
Comment thread internal/commands/connect_doctor_mcp_unix.go
Comment thread internal/commands/connect_run.go Outdated
@jorgemanrubia
jorgemanrubia requested a balanced review from Copilot September 17, 2026 09:20
@jorgemanrubia
jorgemanrubia requested a balanced review from Copilot September 17, 2026 14:15
@jorgemanrubia

Copy link
Copy Markdown
Member Author

🤖 The three suppressed findings are fixed: the profile is shell-quoted in the command doctor's pass message prints, and both the command's help and the skill now say what doctor actually promises — it writes nothing to the connector's ledger and posts nothing to Basecamp, while renewing the profile's own credential, which every command does when its token is due, may still write the credential store.

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

Import validation and several operator-status messages can currently produce unsafe mutations or inaccurate results.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (2)

Previously missed (2) — in code that hasn't changed since the last review.

internal/commands/connect_operator.go:475

  • RerunSkipped also covers LoadUndecided finding that the record is no longer blocked and Commit losing the revision race to the running connector. In those cases this summary incorrectly says the event is still blocked and tells the operator to redispatch again even though it may already be admitted or otherwise decided. Use race-safe wording that asks the operator to check status before retrying.
    internal/commands/connect_process_other.go:6
  • On non-Unix platforms this false is rendered as “no process has that pid” and serialized as pid_exists:false, even though the implementation explicitly cannot answer the question. Represent process existence as unknown (for example with a tri-state result) so status does not report stale lock metadata as proven dead on these platforms.
  • Files reviewed: 37/37 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread internal/connector/ledger_import.go
…check cannot answer

Import checked only what ParseReconciliation had already checked, so a caller
that built the value itself could tag every record under a version this build
does not read. Status reports a lock file's pid as present, absent or unknown
rather than calling it dead where the platform cannot say, and a redispatch
whose prerequisite did not run here sends the operator to status rather than
promising the record is still blocked.
@jorgemanrubia
jorgemanrubia requested a balanced review from Copilot September 17, 2026 14:37

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

The database hold invariant can be bypassed by inserting a tagged unauthorized event directly in an admitted or queued state.

Get a fresh assessment by requesting another Copilot review.

Review details
  • Files reviewed: 37/37 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread internal/connector/ledger_hold.go
…s canceled

The hold's refusal of a first hand-off reached the dispatcher as an error, so
a hold arriving mid-task ended that task as failed; ExposeEvent reports it as
held and the follow-up loop stops asking, so the task finishes and its
sibling waits for a person.

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

Hold handling can incorrectly fail live tasks, and timestamp ties can suppress required follow-up decisions.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (2)

Previously missed (2) — in code that hasn't changed since the last review.

internal/connector/lifecycle.go:323

  • This >= timestamp comparison can treat the authorization that launched this attempt as a decision on the attempt's later failure. Ledger timestamps come directly from time.Now and are not guaranteed to be unique or strictly increasing, so a fast launch/failure (or coarse clock) can give authorized_at == ended_at; the completion notice then suppresses the required new redispatch request. Track causal order with a decision/attempt identifier (or another strictly ordered ledger value) rather than accepting equality between wall-clock timestamps.
    internal/connector/outbox_run.go:913
  • The claim-time fast path repeats the non-causal authorized_at >= ended_at check. If the authorization that launched a retry and that retry's end receive the same wall-clock timestamp, this treats the old authorization as a post-outcome decision and re-renders/cancels the notice without asking for the newly failed outcome. Use the same causally ordered decision/attempt marker as settlement rendering rather than timestamp equality.
  • Files reviewed: 37/37 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread internal/connector/ledger_tasks.go
The dispatcher now treats ExposeEvent's held refusal as no follow-up, so a
hold that lands while a task runs lets that task finish and leaves its
unexposed events for a person; a test drives it through the dispatcher. An
authorization counts as a decision on an attempt only when it is stamped
strictly after that attempt ended: a stamp equal to the end is not evidence
it came after, and the notice asks again.

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

The missing-shadow promotion retry bypasses the required normal connector instance lock.

Get a fresh assessment by requesting another Copilot review.

Review details
  • Files reviewed: 38/38 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread internal/connector/promote.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.

🔵 Needs a closer look

The extensive transactional, crash-recovery, process-control, and stacked-PR behavior warrants final human review.

Review details
  • Files reviewed: 38/38 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

Until the worktree driver lands, status cannot say whether a worktree is
retained, and an operator must not read that as a clean slate.

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

An exposed but not yet pulled instruction can still reach a worker while the durable hold stands.

Get a fresh assessment by requesting another Copilot review.

Review details
  • Files reviewed: 38/38 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread internal/connector/ledger_hold.go
An event the launch exposed carries only a pointer until a worker pulls its
instruction, so a crash during launch and a restart under --hold could still
let that worker fetch the instruction and start work a person had held. The
database refuses a first pull now, and a repeat of one already pulled is
answered; get_dispatch says the connector is held.

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 crash-sensitive ledger migration, SQL state invariants, and worker process termination require final human validation, and PR #739 must merge first.

Review details
  • Files reviewed: 39/39 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

The merge with connect-outbox brings in #736 as main squashed it, and three
things it changed under this branch.

migrationOperator is 9 now, behind the acknowledgement trigger main took as 6
and the tasks, attempts and outbox it pushed to 7 and 8.

Close releases the ledger's registry entry, so a read-only open has to take
one: it went through setup.CheckPrivateFile directly and built a Ledger with
no entry at all, which panicked on Close and, worse, would have dropped the
locks of any Ledger open beside it in the same process — the privacy check
opens a descriptor and closes it. It claims the same per-file entry the
writer's open claims.

Superseding a task now retires its rows in the same statement, by trigger, so
a redispatch that wrote superseded_at by hand retired an unexposed sibling
without returning it: the record stayed dispatched on a dead task and held its
conversation for good. The redispatch supersedes through supersedeTask, which
returns what the task never exposed.

The tests acknowledge and complete as a worker does, pulling the instruction
first: exposure written at launch is not a pull.
@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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants