Skip to content

fix(cli): graceful SIGINT/SIGTERM in team mode; bound --until-empty redispatch - #52

Merged
junhoyeo merged 2 commits into
mainfrom
agent/fix-cli-signals
Jul 17, 2026
Merged

junhoyeo merged 2 commits into
mainfrom
agent/fix-cli-signals

Conversation

@junhoyeo

Copy link
Copy Markdown
Owner

Wave 2/4.

HIGH — no signal handling in root team-execution mode: Ctrl+C hit Go's default disposition, dying before runner.Close and boardSyncer.Finalize — orphaned tmux sessions, board issues left claimed. Now wrapped in signal.NotifyContext so interruption routes through the normal shutdown path.

HIGH — --until-empty redispatched failing retry issues forever with zero backoff: now capped per-issue with backoff; exhausted issues are reported and skipped so the loop terminates.

Also: headless (--no-tui) events now reach stdout as promised by the help text; 'Web dashboard available' no longer printed when dashboard assets aren't embedded; dry-run help/behavior reconciled; dead code removed.

Adversarial review: approved, 0 blocking. go test ./cmd/... ./internal/team/... green after rebase.

junhoyeo added 2 commits July 17, 2026 13:08
A team run that ends in a non-complete phase re-queues its board issue as
retry with the claim cleared, and FindDispatchableIssue treats unclaimed
retry issues as dispatchable. dispatchBoardIssues therefore re-selected a
deterministically failing issue forever with zero backoff, spinning
`board dispatch --until-empty` (and the root team-execution loop, which
shares this function) indefinitely while burning agent runs.

The drain loop now tracks attempts per issue and, once an issue exhausts
its cap (default 3, tunable via --max-attempts), parks it by setting a
sentinel ClaimedBy plus TrackerMeta status and a board comment, then moves
on. Parked issues are hidden from FindDispatchableIssue; `board move <id>
retry` clears the claim to re-enable dispatch. The loop also checks ctx
between iterations so cancellation from the root execution loop is
honored mid-drain.

Constraint: LocalBoardState has no terminal failed state, so parking must reuse the ClaimedBy skip in FindDispatchableIssue
Rejected: sleep backoff between redispatches | delays but does not bound the loop; the cap alone guarantees termination
Rejected: persisting attempt counts in TrackerMeta across drains | per-drain cap already terminates every drain, and parking persists on disk across poll cycles
Confidence: high
Scope-risk: narrow
Directive: parked issues rely on ClaimedBy being non-empty; do not add stale-claim recovery that clears parked:max-dispatch-attempts without revisiting this loop
Not-tested: concurrent dispatchers racing on the same board directory
…ts to stdout

Root team-execution mode returned from run() before the single-agent
signal hook was installed, and runTeamExecutionLoop disables the
team-level handler, so `contrabass --no-tui` (or --dry-run) with
team.execution_mode=team had zero signal handling: Ctrl+C killed the
process via the Go default disposition, skipping runner.Close (orphaning
tmux panes still running agents) and boardSyncer.Finalize (leaving the
issue ClaimedBy="team:<name>", which FindDispatchableIssue skips forever).
run() now wraps the team branch in signal.NotifyContext so interruption
cancels ctx and shutdown flows through the normal exit path.

Also in this commit:
- --no-tui promised "log events to stdout" but runHeadless only wrote to
  the file logger. Headless mode now streams event lines to stdout in
  addition to the log file, making the help text and README accurate.
  Emitting to stdout was chosen over rewording the docs because piping
  events is the useful CI behavior the flag advertises.
- runDryRun checked orch.Run's error for DeadlineExceeded, but Run
  returns nil when its context is done, so the 60s warning was dead code.
  The timeout is now detected on dryCtx.Err().
- Single-agent mode printed "Web dashboard available" even when the
  binary lacks embedded dashboard assets (nil dashboardFS serves no "/"
  route); it now prints an API-only message pointing at the
  dashboard_dist build tag, mirroring team mode's diagnostic.
- Removed dead workflowTimelineProvider (never constructed; cannot even
  satisfy web.TimelineProvider) and buildTeamTasksFromBoardIssue (only a
  test called it; the test now pins buildBoardTeamPlan directly).

Constraint: bubbletea only intercepts Ctrl+C in TUI mode, so headless/dry-run team paths need a process-level handler
Rejected: dedicated handler inside runTeamExecutionApp | run() owns the root ctx and the single-agent branch already installs its handler at the same level
Rejected: correcting help text/README to say events go to the log file | stdout emission is what headless CI consumers need and matches the long-documented behavior
Confidence: high
Scope-risk: narrow
Directive: keep signal registration ahead of the TeamExecutionMode switch; moving it after the switch silently restores the default-kill behavior for team mode
Not-tested: SIGTERM delivery (regression test exercises SIGINT; both share one NotifyContext registration)
@vercel

vercel Bot commented Jul 17, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
contrabass-landing Ready Ready Preview, Comment Jul 17, 2026 4:11am

Request Review

@junhoyeo
junhoyeo merged commit acb83b3 into main Jul 17, 2026
11 checks passed

This branch was successfully deployed

1 active deployment
Preview — 04323976 Deployed Jul 17, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant