Test how basecamp connect fails and recovers, end to end by robzolkos · Pull Request #826 · basecamp/basecamp-cli · GitHub
Skip to content

Test how basecamp connect fails and recovers, end to end - #826

Open
robzolkos wants to merge 3 commits into
connect-e2e/harnessfrom
connect-e2e/failures
Open

robzolkos wants to merge 3 commits into
connect-e2e/harnessfrom
connect-e2e/failures

Conversation

@robzolkos

@robzolkos robzolkos commented Oct 2, 2026 •

Copy link
Copy Markdown
Collaborator

PR 3 of a stack of 8 that tests basecamp connect end to end. Stacked on #823 (the harness). It adds the failure and recovery scenarios, and fixes one real bug they found.

The first commit is the same change as #820 (the connector reports its backlog edges on stderr). The backlog scenario needs it. When #820 merges, that commit drops out on rebase.

Why

#823 holds the connector to the happy path. A connector has to live through more than that: a Ctrl-C, a second copy started from another repo, a disconnect in Basecamp, a rate-limited token endpoint, a dropped or silent cable, a flaky read, a crash in the middle of a handoff, and a backlog. Each of these is a fault that Basecamp or the operator can really cause. Unit tests prove the parts. They do not prove what a person sees when the real binary meets the fault.

What

A product fix (its own commit, with unit tests): the connector now stops when Basecamp no longer takes its agent's credential.

The bug: a running connector learned of a disconnect only from the event feed. The feed sends a request to Basecamp only when it connects. While its live connection stays up, it sends nothing. Admission and intake send reads all the time, but to them a refusal was only one failed read. Admission blocked the mention as read_failed, intake kept its last project list, and the connector ran on. Every mention to the agent was blocked, with no explanation, for as long as the socket stayed up.

Reproduced end to end: while the connector streams, disconnect the agent, or connect it on another computer and let its token come due. Then the operator mentions the agent. The mention is blocked read_failed and the connector does not exit.

The fix is a credential watch (internal/commands/connect_credential.go) on the reads that admission and intake make:

  • When the token endpoint refuses a renewal (invalid_client), that is already Basecamp's answer. The run stops at once.
  • A read answered 401 only causes a check, because something other than Basecamp can answer 401. The watch asks Basecamp itself (the same confirmAgentRefused the feed's stop uses). It stops the run only if Basecamp confirms.

In both cases the connector exits 3 with the message the feed's stop uses ("... was disconnected in Basecamp, or connected on another computer", and the connect setup hint), and status records it as disconnected.

The scenarios (e2e/connect/failures_test.go). No knobs, no sleeps used to sync. Every wait is on a line or on the fake's state, with a deadline that only bounds a failure.

Fake changes (only additions): Server.Disconnect (revokes the agent's tokens and clears its secret, so a read gets 401 and a mint gets invalid_client), and AgentClient.TokenLifetime (tokens that say they last as little as a second, so a renewal comes due during a test). Both have tests in the fake's own package.

Harness changes: Profile / SetupProfile / ConnectProfile to connect a second agent, AnotherComputer (a second home against the same Basecamp), and Connector.Pid.

Tests

  • SIGINT on a running connector: it logs connector: shutting down with signal=interrupt, exits 130, and logs no ERROR.
  • A second connector for the same agent, from another directory with the same XDG_STATE_HOME: it exits 5 at once, writes nothing to stdout, and its refusal names the pid of the first connector. The fake sees no request from it, not even for a token. The test checks only the exit code and the pid, so the new wording in Say why one connector serves every repo, and drop references to redispatch #821 also passes.
  • Two agents (a second agent person and OAuth client in the world) run at the same time. Each gives one request for its own mention and not_addressed for the other agent's mention.
  • Disconnected while running: the next read gets 401 and the connector exits 3 with the disconnect message. No request is printed. A second variant: the cable drops and the reconnect's stream ticket is refused. That also exits 3.
  • Connected on another computer while running (the invalid_client trigger): the world's tokens last one second. Another home runs auth agent connect, which rotates the secret, as Basecamp does, and leaves this computer's token alive. When the token comes due, the next mention's read renews it, the renewal gets invalid_client, and the connector exits 3. The one wait on the clock is the token's own expiry, which auth status --json reports. That expiry is the premise of the scenario, not a guess about timing.
  • Token 429 at start (Retry-After: 1, once, with the stored token due): it logs connector: could not get the agent's token yet, comes up, and hands off a mention.
  • Cable drop: the reconnect is held at its stream ticket, an event is put on the poll lane only, and then the reconnect is released. The event arrives by catch-up (pointer lane poll) and gives exactly one request.
  • Stale cable (StopPings): the connector reconnects and hands off a later live mention. This runs once, because staleness is 7.5s and nothing can make it shorter.
  • A recording read fails twice, then serves: one admitted event line, one request, and three reads of the comment.
  • Crash during handoff: connect --hold, mention, wait for its admitted event line (no request), kill -9, basecamp connect release -P agent, restart. The request for that event_id is printed once (within the grace period).
  • Backlog: recording reads are held open and DefaultBacklogWarn + admission.DefaultWorkers events are emitted live (each worker holds one event at its read). The warn line arrives with warn_at=1000. The reads are released and the recover line arrives.

The 11 scenarios and the 5 from #823 run in parallel in about 13s (the stale cable sets the floor). go test -race -count=10 ./e2e/connect passed in 154s, and go test -count=20 ./e2e/connect passed in 190s, with no failures. bin/ci is green.


Summary by cubic

Fixes the connector running on after its agent is disconnected in Basecamp or connected on another computer, and tests how basecamp connect fails and recovers, end to end.

The fix

  • A running connector learned of a disconnect only from the event feed, which asks Basecamp for nothing while its live connection stays up; admission and intake saw the refusal as one failed read, so every mention was blocked read_failed and nothing said why.
  • A credential watch over admission's and intake's reads stops the run: a refused token renewal ends it at once, and a 401 read ends it only when Basecamp confirms the agent is gone.
  • The connector exits 3 with the disconnect message, and status records it as disconnected.

The tests

  • 11 scenarios cover SIGINT, a second connector, two agents, disconnects, a rate-limited token, cable drops and staleness, flaky reads, a crash during handoff, and a backlog; each waits on a log line or fake state, never on a sleep.
  • The fake can disconnect an agent (tokens revoked, secret cleared) and mint short-lived tokens so a renewal comes due within a test; the harness handles a second agent and a second home.
  • The backlog queue now reports its warning, pause, resume and recovery edges on the logger, which production never wired to anything before.

Written for commit 93b287a. Summary will update on new commits.

Review in cubic

The queue between intake and admission decides four edges: the backlog
crossing its warning depth, an offer waiting for room (which stops the
feed read), that wait ending, and the backlog falling back below the
warning depth. It handed them only to optional callbacks, and
runConnect sets none, so in production every edge went nowhere. The
package says the pause is reported rather than hidden; it was hidden.

The queue now reports each edge on its logger before running any
callback, with the depth and the threshold crossed, and says plainly
when reading the feed is paused and why. Intake already adopts the
connector's logger for the queue, so every caller that builds intake
gets the report and none can forget to wire it. A callback that panics
is contained after the report, so it cannot silence it.
A running connector learned that its agent was disconnected in Basecamp,
or connected on another computer, only from the event feed. The feed
asks Basecamp for something only when it connects, and while its live
connection stays up it asks for nothing. Admission and intake ask all
the time, but to them a refusal was one failed read among others:
admission blocked the mention it was deciding as read_failed, intake
kept the projects it last read, and the connector ran on. Every mention
addressed to the agent was blocked, and nothing said why, for as long as
the socket held.

Reproduced end to end against the fake Basecamp: disconnect the agent,
or connect it on another computer and let its token come due, while the
connector streams; the operator's next mention is blocked read_failed
and the connector does not exit.

A credential watch now sees the reads admission and intake make. A
token renewal the token endpoint refuses is Basecamp's answer already,
and stops the run at once. A read answered 401 is only a reason to ask,
since something other than Basecamp can answer 401: the watch asks
Basecamp itself, as the feed's own stop does, and stops the run only
when it confirms. Either way the connector exits 3 with the words the
feed's stop uses, and status records it as disconnected.
Every fault is one Basecamp can produce, met the way the connector would
meet it, with nothing in the CLI told a test is running:

- SIGINT on a running connector logs that it is shutting down and exits
  130.
- A second connector for the same agent, from another directory sharing
  the state directory, exits 5 at once, names the process holding the
  agent, and asks Basecamp for nothing.
- Two agents connected on one computer run side by side, and each hands
  off only its own mention.
- An agent disconnected while its connector runs stops it with exit 3,
  whether the next read or the feed's reconnect meets it first. An agent
  connected on another computer stops it at its next token renewal.
- A token endpoint rate-limited at start is waited out, said, and the
  connector comes up and hands off a mention.
- An event committed while the cable is down arrives by the reconnect's
  catch-up, once.
- A cable that stops pinging is called stale and replaced.
- A recording read that fails twice then serves is admitted in one
  decision.
- A mention admitted under --hold by a connector then killed is handed
  off once after `connect release` and a restart.
- A backlog past its warning depth, behind held reads, is reported, and
  its recovery too.

The fake gains Disconnect (the agent's tokens revoked, its secret
cleared) and AgentClient.TokenLifetime, so a renewal comes due within a
test; the harness gains a profile parameter for a second agent and
AnotherComputer for a second home against the same Basecamp.
Copilot AI balanced review requested due to automatic review settings October 2, 2026 18:33
@github-actions github-actions Bot added commands CLI command implementations tests Tests (unit and e2e) labels Oct 2, 2026

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.

Copilot review overview

🔵 Needs a closer look

Credential monitoring changes concurrent production shutdown behavior and warrants final human review.

Review effort: Balanced
Findings: None

What changed in this PR

Extends #823’s end-to-end harness with connector failure and recovery scenarios, and fixes detection of refused agent credentials while the feed stays connected.

Changes:

  • Stops the connector when credential refusal is confirmed.
  • Reports backlog warning, pause, resume, and recovery transitions.
  • Adds failure scenarios and supporting fake-server and harness capabilities.

[!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 Description
internal/​connector/​seam_contract_test.go Tests backlog logging through intake.
internal/​connector/​queue.go Logs backlog transitions.
internal/​connector/​queue_test.go Tests logging before callback panics.
internal/​connector/​intake.go Clarifies queue logger documentation.
internal/​connector/​fakebasecamp/​world.go Adds token lifetime and client ownership fields.
internal/​connector/​fakebasecamp/​oauth.go Implements disconnects and configurable token lifetimes.
internal/​connector/​fakebasecamp/​doc.go Documents new fake capabilities.
internal/​connector/​fakebasecamp/​connect_test.go Tests token lifetimes and disconnects.
internal/​commands/​connect_run.go Integrates credential monitoring and disconnect handling.
internal/​commands/​connect_credential.go Watches reads and renewals for credential refusal.
internal/​commands/​connect_credential_test.go Tests credential-watch decisions.
e2e/​connect/​harness_test.go Supports multiple profiles and separate homes.
e2e/​connect/​failures_test.go Adds failure and recovery scenarios.
e2e/​connect/​connector_test.go Adds profile-specific startup and process ID access.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@cubic-dev-ai cubic-dev-ai Bot 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.

5 issues found across 14 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="internal/connector/fakebasecamp/world.go">

<violation number="1" location="internal/connector/fakebasecamp/world.go:148">
P3: A negative `TokenLifetime` silently falls through to the one-hour default, although only zero is documented as the default; tests can then miss the expected renewal. Reject negative values in `World.check` so invalid setup cannot silently change the token lifetime.</violation>
</file>

<file name="internal/commands/connect_credential.go">

<violation number="1" location="internal/commands/connect_credential.go:69">
P2: A transient confirmation failure consumes this 401 signal just like a confirmed-valid credential; `confirmAgentRefused` discards the error, and the watch retries only after another 401. Preserve or retry unconfirmed suspects with backoff so a one-off outage cannot leave a disconnected connector running.</violation>

<violation number="2" location="internal/commands/connect_credential.go:98">
P3: The watch fires on any error carrying `auth.ErrAgentCredentialRefused`, but the auth layer also attaches that cause to stored refusal *holds* — including time-limited ones (`invalid_grant`, bare 401/403) that `Manager` reports as refreshable and expiring on their own (agent_hold.go: `e.Cause = errors.Join(ErrAgentCredentialRefused, errMintHeld)` for `mintHoldRefused` regardless of `Until`). While such a hold is stored, every `AccessToken` through `watchedTokens` stops the connector with exit 3 and status `disconnected`, telling the user to re-run setup, even though the hold would resolve without reconnecting. The refusal is not always definitive; only a permanent `invalid_client` hold is.</violation>
</file>

<file name="internal/connector/queue.go">

<violation number="1" location="internal/connector/queue.go:336">
P2: Repair-worker offers can raise these edges while the feed loop keeps consuming, so the pause/resume messages misstate feed state; describe queue admission waiting instead.</violation>
</file>

<file name="e2e/connect/failures_test.go">

<violation number="1" location="e2e/connect/failures_test.go:202">
P2: This test passes if the connector retries immediately: `limited.Times = 1` lets the next token request succeed, and the assertion only checks the warning. Assert that renewal is not retried until the 1-second `Retry-After` expires.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

case err := <-w.refused:
return err
case <-w.suspect:
if confirm(ctx) {

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.

P2: A transient confirmation failure consumes this 401 signal just like a confirmed-valid credential; confirmAgentRefused discards the error, and the watch retries only after another 401. Preserve or retry unconfirmed suspects with backoff so a one-off outage cannot leave a disconnected connector running.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At internal/commands/connect_credential.go, line 69:

<comment>A transient confirmation failure consumes this 401 signal just like a confirmed-valid credential; `confirmAgentRefused` discards the error, and the watch retries only after another 401. Preserve or retry unconfirmed suspects with backoff so a one-off outage cannot leave a disconnected connector running.</comment>

<file context>
@@ -0,0 +1,114 @@
+		case err := <-w.refused:
+			return err
+		case <-w.suspect:
+			if confirm(ctx) {
+				return errAgentCredentialNotTaken
+			}
</file context>

log.Info("the backlog fell back below its warning depth",
"depth", edge.depth, "warn_at", q.warnAt)
case edgePause:
log.Warn("the backlog reached its pause depth; reading the feed is paused until admission makes room, so the backlog cannot outgrow memory",

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.

P2: Repair-worker offers can raise these edges while the feed loop keeps consuming, so the pause/resume messages misstate feed state; describe queue admission waiting instead.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At internal/connector/queue.go, line 336:

<comment>Repair-worker offers can raise these edges while the feed loop keeps consuming, so the pause/resume messages misstate feed state; describe queue admission waiting instead.</comment>

<file context>
@@ -310,6 +320,27 @@ func (q *Queue) fire(edge queueEdge) {
+		log.Info("the backlog fell back below its warning depth",
+			"depth", edge.depth, "warn_at", q.warnAt)
+	case edgePause:
+		log.Warn("the backlog reached its pause depth; reading the feed is paused until admission makes room, so the backlog cannot outgrow memory",
+			"depth", edge.depth, "pause_at", pauseAt)
+	case edgeResume:
</file context>

for _, l := range out.Logs {
waited = waited || (l.Level == "WARN" && strings.HasPrefix(l.Msg, "connector: could not get the agent's token yet"))
}
assert.True(t, waited, "the wait is said\n%s", out.Transcript())

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.

P2: This test passes if the connector retries immediately: limited.Times = 1 lets the next token request succeed, and the assertion only checks the warning. Assert that renewal is not retried until the 1-second Retry-After expires.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At e2e/connect/failures_test.go, line 202:

<comment>This test passes if the connector retries immediately: `limited.Times = 1` lets the next token request succeed, and the assertion only checks the warning. Assert that renewal is not retried until the 1-second `Retry-After` expires.</comment>

<file context>
@@ -0,0 +1,404 @@
+	for _, l := range out.Logs {
+		waited = waited || (l.Level == "WARN" && strings.HasPrefix(l.Msg, "connector: could not get the agent's token yet"))
+	}
+	assert.True(t, waited, "the wait is said\n%s", out.Transcript())
+	assert.Equal(t, 1, fault.Hits(), "the token was rate-limited once")
+	assert.Len(t, out.RequestsFor(ev.ID), 1)
</file context>

// whole seconds of at least one; zero is Basecamp's hour. The fake
// does not retire a token when it runs out: the client renews by what
// it was told, and a test can wait for that to come due.
TokenLifetime time.Duration

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.

P3: A negative TokenLifetime silently falls through to the one-hour default, although only zero is documented as the default; tests can then miss the expected renewal. Reject negative values in World.check so invalid setup cannot silently change the token lifetime.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At internal/connector/fakebasecamp/world.go, line 148:

<comment>A negative `TokenLifetime` silently falls through to the one-hour default, although only zero is documented as the default; tests can then miss the expected renewal. Reject negative values in `World.check` so invalid setup cannot silently change the token lifetime.</comment>

<file context>
@@ -141,12 +141,20 @@ type AgentClient struct {
+	// whole seconds of at least one; zero is Basecamp's hour. The fake
+	// does not retire a token when it runs out: the client renews by what
+	// it was told, and a test can wait for that to come due.
+	TokenLifetime time.Duration
 }
 
</file context>

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.

P3: The watch fires on any error carrying auth.ErrAgentCredentialRefused, but the auth layer also attaches that cause to stored refusal holds — including time-limited ones (invalid_grant, bare 401/403) that Manager reports as refreshable and expiring on their own (agent_hold.go: e.Cause = errors.Join(ErrAgentCredentialRefused, errMintHeld) for mintHoldRefused regardless of Until). While such a hold is stored, every AccessToken through watchedTokens stops the connector with exit 3 and status disconnected, telling the user to re-run setup, even though the hold would resolve without reconnecting. The refusal is not always definitive; only a permanent invalid_client hold is.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At internal/commands/connect_credential.go, line 98:

<comment>The watch fires on any error carrying `auth.ErrAgentCredentialRefused`, but the auth layer also attaches that cause to stored refusal *holds* — including time-limited ones (`invalid_grant`, bare 401/403) that `Manager` reports as refreshable and expiring on their own (agent_hold.go: `e.Cause = errors.Join(ErrAgentCredentialRefused, errMintHeld)` for `mintHoldRefused` regardless of `Until`). While such a hold is stored, every `AccessToken` through `watchedTokens` stops the connector with exit 3 and status `disconnected`, telling the user to re-run setup, even though the hold would resolve without reconnecting. The refusal is not always definitive; only a permanent `invalid_client` hold is.</comment>

<file context>
@@ -0,0 +1,114 @@
+func (t watchedTokens) AccessToken(ctx context.Context) (string, error) {
+	token, err := t.provider.AccessToken(ctx)
+	if errors.Is(err, auth.ErrAgentCredentialRefused) {
+		t.watch.refuse(err)
+	}
+	return token, err
</file context>

This branch has not been deployed

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

Labels

commands CLI command implementations tests Tests (unit and e2e)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants