Post the connector's own lifecycle messages exactly once, or not at all - #739
jorgemanrubia wants to merge 61 commits into
Conversation
e0cda3d to
9428454
Compare
ff18d50 to
4bb399e
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Operator recovery is not exposed, advertised remediation is unavailable or inaccurate, and resend reconciliation state is not reset.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Introduces a durable lifecycle-message outbox for the connector, providing guarded posting, reconciliation, and crash recovery.
Changes:
- Adds transactional lifecycle-message creation and resolution.
- Implements Basecamp posting, reconciliation, and exactly-once safeguards.
- Integrates outbox processing with connector startup and shutdown.
[!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 summaries
| File | Description |
|---|---|
internal/connector/outbox.go |
Defines outbox schema, states, queries, and resolution. |
internal/connector/outbox_run.go |
Implements sending and reconciliation. |
internal/connector/outbox_kill_unix_test.go |
Tests SIGKILL recovery behavior. |
internal/connector/outbox_invariants_test.go |
Tests outbox invariants and failure handling. |
internal/connector/outbox_fakes_test.go |
Provides outbox test fixtures. |
internal/connector/outbox_basecamp.go |
Implements SDK-backed posting and listing. |
internal/connector/outbox_basecamp_test.go |
Tests Basecamp poster behavior. |
internal/connector/lifecycle.go |
Renders lifecycle notices and installs hooks. |
internal/connector/lifecycle_test.go |
Tests lifecycle templates and rules. |
internal/connector/ledger.go |
Registers the outbox migration. |
internal/commands/connect_run.go |
Runs and flushes the outbox with the connector. |
Review details
Suppressed comments (2)
internal/connector/lifecycle.go:75
- These notices direct users to
basecamp connect redispatch, butNewConnectCmdregisters only thesetupsubcommand (internal/commands/connect.go:28-64) and there is no redispatch implementation in the repository. Failed/unknown events therefore advertise a recovery action users cannot perform. Add and register the command, or do not emit it as remediation.
redispatch := " Needs a person: basecamp connect redispatch " + id
internal/connector/lifecycle.go:86
- The PR description says every event named by a completion notice—including one that succeeded without a reported reply—includes its redispatch command, but this branch omits it for this case. Once the advertised redispatch command exists, append the shared
redispatchsuffix here as well.
case e.Outcome == OutcomeSucceeded && e.ReplyID == nil:
return "Event " + id + ": succeeded, with no reply reported."
- Files reviewed: 11/11 changed files
- Comments generated: 4
- Review effort level: Balanced (auto)
Note
Copilot is running an experiment and ran this review at Balanced.
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
9428454 to
7866973
Compare
4bb399e to
7e5d478
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Reconciliation can be starved, partial database scans can permit incorrect reply adoption, and one completion case contradicts the documented contract.
Get a fresh assessment by requesting another Copilot review.
Review details
- Files reviewed: 12/12 changed files
- Comments generated: 3
- Review effort level: Balanced (auto)
Note
Copilot is running an experiment and ran this review at Balanced.
7866973 to
b5efc24
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Project scoping is not wired into the dispatcher, and cancellation can interrupt a subsequent Claude turn.
Get a fresh assessment by requesting another Copilot review.
Review details
- Files reviewed: 40/40 changed files
- Comments generated: 2
- Review effort level: Balanced (auto)
Note
Copilot is running an experiment and ran this review at Balanced.
b5b4a52 to
6479301
Compare
There was a problem hiding this comment.
🔵 Needs a closer look
Durable state transitions, crash recovery, and non-idempotent external writes warrant final human review despite comprehensive tests.
Review details
- Files reviewed: 12/12 changed files
- Comments generated: 0 new
- Review effort level: Balanced (auto)
Note
Copilot is running an experiment and ran this review at Balanced.
There was a problem hiding this comment.
🟡 Changes recommended
A definitively refused guard post is still reported to workers as acknowledged, preventing their fallback acknowledgement.
Get a fresh assessment by requesting another Copilot review.
Review details
- Files reviewed: 12/12 changed files
- Comments generated: 1
- Review effort level: Balanced (auto)
Note
Copilot is running an experiment and ran this review at Balanced.
There was a problem hiding this comment.
🟡 Changes recommended
Guard-refusal concurrency and unbounded reconciliation passes can suppress acknowledgements or significantly delay pending lifecycle messages.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
internal/connector/outbox_run.go:408
- This drains the entire stale backlog while holding
o.mu, and eachPoster.Listmay consume the 30-secondAdoptionScanTimeout. A burst of uncertain sends can therefore block all pending guard and holding messages forbacklog × 30s(hundreds of rows can accumulate during the one-minute reconcile delay), despite each individual listing being bounded. Reconcile a bounded batch or time slice per run loop, as sending already does, so posting and reconciliation cannot starve each other.
- Files reviewed: 12/12 changed files
- Comments generated: 2
- Review effort level: Balanced (auto)
Note
Copilot is running an experiment and ran this review at Balanced.
3c6309d to
3e0e5af
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Canceled outbox intents can incorrectly suppress legitimate worker replies during adoption.
Get a fresh assessment by requesting another Copilot review.
Review details
- Files reviewed: 12/12 changed files
- Comments generated: 1
- Review effort level: Balanced (auto)
Note
Copilot is running an experiment and ran this review at Balanced.
From a fourteenth Opus adversarial review, which found nothing blocking and proved the rebase clean: a still-running notice waiting behind a reconciliation, a slow send or a restart could go out after the worker had finished and reported, leaving the connector's last word on a finished task saying it was still working. The claim now checks the attempt is still live, as it already does for the guard and the holding reply.
There was a problem hiding this comment.
🔵 Needs a closer look
Startup can emit stale progress notices and suppress hard ledger failures, while reply filtering adds an unbounded redundant query path.
Review details
Suppressed comments (3)
Previously missed (1) — in code that hasn't changed since the last review.
internal/commands/connect_run.go:381
- A pending still-running intent from a crashed process is flushed here before
Dispatcher.Rungets a chance to recover that process's live attempts. BecauseclaimIntentonly checks the ledger'sattempts.state, it still sees the orphan as live and can post “Still working on this”; immediately afterward dispatcher recovery ends the attempt aslostand emits a completion notice. Reorder startup so oldsendingintents are reconciled first, dispatcher recovery settles/verifies orphaned attempts next, and only then pending intents are flushed.
internal/connector/outbox_run.go:205
- A hard send-side ledger error is lost if the startup deadline expires concurrently.
sendNextdeliberately usesWithoutCancelwhen recording a receipt/refusal, so that write/readback can fail afterctx.Err()becomes non-nil; this condition then returns success and lets the connector start despite an unsettled ledger. Suppress only errors caused by the caller's cancellation, and propagate unrelated errors regardless of the current context state.
if err := o.flushSome(ctx, 0, true); err != nil && ctx.Err() == nil {
return fmt.Errorf("connector: send lifecycle messages on start: %w", err)
}
internal/commands/connect_run.go:310
LifecycleFilteredReplieshas already removed lifecycle messages with one destination-scoped ledger scan, but this predicate performs two additional ledger queries for every remaining reply. Those queries usecontext.Background(), so a busy ledger can add up to the retry timeout per reply and run past the dispatcher'sAdoptionBudget. Do not install the redundant predicate when usingLifecycleFilteredReplies, or make filtering return/reuse its receipt set under the caller's context.
Replies: connector.LifecycleFilteredReplies{Lister: poster, Ledger: ledger},
IsLifecycleMessage: outbox.IsLifecycleMessage,
Lines: lines, Logger: logger,
- Files reviewed: 12/12 changed files
- Comments generated: 0 new
- Review effort level: Balanced (auto)
Note
Copilot is running an experiment and ran this review at Balanced.
There was a problem hiding this comment.
🔵 Needs a closer look
A transition can invalidate a still-running or holding intent after its claim but before its post begins.
Review details
Suppressed comments (1)
internal/connector/outbox_run.go:293
- The eligibility checks in
claimIntentare released before this request starts. A dispatcher can commitEndAttemptin that gap (or a redispatch can move ano_routeevent), after which this call still posts the already-claimed “still working” or holding message. In the successful-attempt case no completion notice follows, so “still working” can become the connector’s final message after work ended. Coordinate attempt/admission transitions with request initiation (or add a durable handshake those transitions honor), and add a test that pauses after claim but beforePost.
receipt, postErr := o.opts.Poster.Post(postCtx, intent.Destination, intent.Body)
- Files reviewed: 12/12 changed files
- Comments generated: 0 new
- Review effort level: Balanced (auto)
Note
Copilot is running an experiment and ran this review at Balanced.
There was a problem hiding this comment.
🔵 Needs a closer look
Canceled, never-posted intents can incorrectly suppress legitimate worker replies.
Review details
Suppressed comments (1)
internal/connector/outbox_run.go:846
- This also records bodies from
canceledintents as possibly posted. Every canceled path proves no message exists (canceled before claim, orErrNotPostedafter claim), so a legitimate worker reply with the same normalized text is incorrectly filtered out forever. Exclude canceled rows from the body fallback; a refused intent will become eligible again automatically if a person resolves it back to pending.
rows, err := r.Ledger.db.QueryContext(ctx, `
SELECT receipt_id, body FROM outbox WHERE message_kind = ? AND recording_id = ?`, string(messageKind), recordingID)
- Files reviewed: 12/12 changed files
- Comments generated: 0 new
- Review effort level: Balanced (auto)
Note
Copilot is running an experiment and ran this review at Balanced.
…g-attempt arm From a fifteenth Opus adversarial review, which found nothing blocking and re-derived every invariant: the comment claimed a restart among the cases, but a start flushes before the dispatcher settles a crashed process's attempts, so that attempt still reads running and its notice goes out — followed by the settlement's own. The ErrNoRows arm now has the test its siblings have.
There was a problem hiding this comment.
🔵 Needs a closer look
Attempt settlement can race with still-running posts, and redundant lifecycle lookups can exceed adoption and shutdown bounds.
Review details
Suppressed comments (2)
internal/connector/outbox_run.go:407
- This liveness check happens only while the intent is claimed. After the transaction commits
sending, the dispatcher can end the attempt beforePoster.Postruns, so the connector can post “Still working” after the attempt ended. If every event succeeded with a reply, no completion intent is created, leaving this stale notice as the connector's last word. Coordinate attempt settlement with an in-flight still-running intent, or have settlement enqueue a terminal notice whenever one is alreadysending.
switch err := tx.QueryRowContext(ctx, `SELECT state <> 'ended' FROM attempts WHERE id = ?`, in.AttemptID).Scan(&live); {
case errors.Is(err, sql.ErrNoRows):
live = false
case err != nil:
return fmt.Errorf("connector: outbox claim still-running %d: %w", in.ID, err)
internal/commands/connect_run.go:309
LifecycleFilteredRepliesalready removes lifecycle receipts and unreceipted bodies with one destination-scoped query. Passing this callback as well makesAdoptableReplyperform up to two extra SQLite queries per remaining reply, and those queries usecontext.Background(), so a busy ledger or high-volume conversation can overrunAdoptionBudgetand delay shutdown. Leave this callback nil when using the filtered lister.
IsLifecycleMessage: outbox.IsLifecycleMessage,
- Files reviewed: 12/12 changed files
- Comments generated: 0 new
- Review effort level: Balanced (auto)
Note
Copilot is running an experiment and ran this review at Balanced.
There was a problem hiding this comment.
🟡 Changes recommended
Reply filtering can misclassify cross-kind IDs and suppress valid replies using canceled intents.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (1)
internal/connector/outbox_run.go:849
- Canceled intents are included in
unreceipted, although every cancellation path proves that no lifecycle message exists (get_dispatch/no-longer-called-for cancels before posting, andErrNotPostedmeans Basecamp created nothing). Consequently, a genuine worker reply with the same normalized text is removed from adoption forever. Exclude canceled rows from this lookup; indeterminate/abandoned rows should remain because their messages may exist.
SELECT receipt_id, body FROM outbox WHERE message_kind = ? AND recording_id = ?`, string(messageKind), recordingID)
- Files reviewed: 12/12 changed files
- Comments generated: 1
- Review effort level: Balanced (auto)
Note
Copilot is running an experiment and ran this review at Balanced.
…cated listing From a sixteenth Opus adversarial review, which found nothing blocking: the start checked its context before the error class when sending, so a bound expiring in the same breath hid a ledger failure; and a boost or comment listing the SDK truncated at its page cap was retried for hours before going indeterminate, though waiting cannot make it shorter.
…per reply From Copilot: the reply filter treated a canceled intent's words as a message that might exist, though cancellation means nothing was posted, so a worker's reply reading the same was dropped from adoption; and the id-only predicate beside the filtered lister asked the ledger again for every reply, outside the adoption budget, for what the lister had already removed.
From an eighteenth Opus adversarial review: dropping the id-only predicate left the filter's receipt arm as the only thing keeping a sent notice out of the adopted-reply rule, and deleting that arm left the whole suite green. A still-running notice sits exactly in the window the rule scans, so this is reachable, not theoretical.
0fc79b0 to
caab8f1
Compare
#736 landed on main as one commit, so this branch and its base both carry its work under different sha. Merging the base in resolves that once, at the tip, rather than replaying thirty-three commits main already has. Seventeen of the twenty conflicts were add/add on files this branch never touched beyond the dispatcher's head it forked from; each took the dispatcher's version wholesale. The three real ones: connect.go the stdout wire lists lifecycle messages (ours), on the dispatcher's wording for the token socket. connect_run.go the outbox's wiring, start, run and shutdown flush, and the reply lister that leaves the connector's own notices out. ledger.go the migration list. The outbox is migration 8, not 7. It was 7 on #736's head — 6 for the tasks and attempts it builds on, 7 for the outbox. Main took 6 for the acknowledgement trigger, the dispatcher's tables moved to 7, and this follows at 8. The numbers move only because no ledger has applied them yet; once one has, the number is fixed. A worker now reports only what it pulled: exposure at launch is not the pull. Four outbox tests acknowledged or completed straight off a launch, so they call get_dispatch first, as a worker does.
|
🤖 Landed in #748, Combining them is what found the defects none of us could see alone — a |
Stacked on Dispatch admitted mentions to Claude Code workers; do not merge before it.
The connector can dispatch a mention to a worker, but it has no voice of its own. When the worker is slow to start, nobody sees that the request was heard. When a project has no route, the mention gets no answer. When a worker fails, is lost, runs out its deadline or is stopped by a shutdown, no one is told which events need a person. Worse, anything the connector did post would be at risk of posting twice after a crash, because a request can reach Basecamp and the process can die before it learns the message id.
Originally tracked in Lifecycle messages and the outbox.
The worker still acknowledges and replies in its own words. That instruction is the dispatch prompt's first step before any work, and the worker reports it through
ack_dispatch. The connector adds only fixed-form messages for moments when no worker can speak. They're built from ledger records: ids, states, stop reasons and times. No content, no model, no free text. A missing message costs less than a doubled one, so every lifecycle message goes through one outbox that never guesses.The messages
get_dispatchwithin 30 seconds.get_dispatchcancels it in its own transaction. A guard that already went out is reported back to the worker asguard_acknowledged, even when the task was created or the follow-up joined afterwards.basecamp connect redispatchcommand they need; an event that succeeded without reporting a reply is only named, since the work behind it was reported done. An attempt where every event succeeded with a reply gets no notice.The outbox, and the invariants it holds (each has a test that fails without it, bar one noted below; the DB enforces them where SQL can):
sendingrow. Claiming the intent (committed) is the only way to a request.sendingis ever sent again automatically. A failed or unanswered request is reconciled by listing the destination, never by posting. That happens once the request has had time to land, and a failing listing backs off before it gives up asindeterminate. A single flush also refuses to claim one intent twice.indeterminate, for a person to resolve as sent, abandoned or resend.pending → sending | canceled,sending → sent | indeterminate,sending → canceledwhen Basecamp refused the request (which creates nothing), andindeterminate → sent | abandoned | pending. Those last three, and resending a refused intent, happen only by a person's decision.get_dispatchcancels a pending guard in its own transaction, and a guard that already went out is reported to every worker as acknowledged, including tasks created and follow-ups joined afterwards.indeterminate. A flush never claims the same intent twice.On start, before intake, admission or dispatch run, the outbox reconciles every
sendingintent a previous process left, then sends what is due. If the ledger can't read or settle an intent, the connector does not start. A listing that fails backs off and does not block the start. The whole step is time-bounded, and sending stops at the first request that may not have landed, so a slow Basecamp delays the start instead of holding it up; the running outbox carries on from there. One wait is deliberate: an intent that wentsendingless than a minute before the restart may still be landing. That is the normal case when a supervisor restarts a crashed connector. Listing it at once could only mark itindeterminatefor want of patience, so the running outbox reconciles it as soon as it is a minute old.The adopted-reply rule keeps the connector's own notices out by reading the destination itself: a notice with a receipt is left out by its id, and one still without a receipt by its words at that destination. Nothing else is needed, so the dispatcher gets no id-only predicate beside it.
Posting uses the agent's own SDK client. The SDK makes one attempt at a non-idempotent create, and a test holds that.
--shadowinstalls no hooks and posts nothing, so a promoted shadow ledger carries nothing to send. A future hold marker has a seam to pause sending while reconciliation continues.basecamp connectruns the sender beside the dispatcher and flushes the notices of attempts stopped at shutdown, within a bound.One invariant has no test that fails without it: the claim's check that the row it moved was still pending. The select and the update share one immediate transaction, so it cannot fire; it is there so a later refactor that separates them cannot quietly send twice.
Evidence
sendingand the receipt is tested with a real child process and SIGKILL. When the request had reached the server, the restart adopts exactly one message. When it hadn't, the intent becomesindeterminate. In both cases the restart makes no request.sendingrow, and no guard double-acknowledgement. Two blocking findings came out of them and are fixed: a failing reconciliation listing was retried every tick with no backoff, and the check that keeps the connector's own notices out of the adopted-reply rule applied to the whole ledger instead of the destination. Fourteen smaller findings are fixed too.Fail-first proofs (80 reverts, each red)
Hook error swallowed; no key conflict handling; claim that does not commit
sending; claim that also pickssending; post error returning the intent to pending; a flush claiming twice; stale age ignored; any match adopted; ownership unchecked; rivals unchecked; body ignored in matching; a failed listing settling; receipt index dropped; receipt made mutable; edge trigger off; resolve from any state; resolve without who; get_dispatch cancel trigger off; fired-before-task trigger off; claim not marking fired; guard not delayed; guard for non-requests; guard for a discarded record; hold ignored; succeeded-without-reply silent; blocked silent; notice when all succeeded; returned events named; blocked not read back from records; holding reply for other blocks; tag stripping off; other people's messages listed; a short Campfire listing accepted; restart resendingsending(the kill test); a sent notice not recognized by its receipt, and an unreceipted one not recognized by its words; no reconciliation backoff; never giving up; retrying an unlistable destination; reconciling a young sending intent at start; rivals without indeterminate or abandoned; unreceipted notices adoptable; a post ignoring the flush deadline; duplicate Campfire lines; a 404 retried.