Skip to content

The Failed run path - #19

Merged
JacobStephens2 merged 4 commits into
mainfrom
issue-5
Sep 25, 2026
Merged

JacobStephens2 merged 4 commits into
mainfrom
issue-5

Conversation

@JacobStephens2

Copy link
Copy Markdown
Owner

Summary

Any failure after the worktree exists now goes down the Failed run path. A draft PR the agent leaves is marked ready.

 run
   preflight
+  interrupted? → fail "interrupted" (no worktree yet)
   Worktree::create_fresh
   implement
     session::run                     # claude in its own process group
+      on SIGINT/SIGTERM → SIGTERM group, SIGKILL after 10s, "interrupted"
     push
     check PR (exists, open, targets Base branch)
+    draft → gh pr ready
+    interrupted? → "interrupted"
+  on error → failed_run::fail
+    abort unfinished merge
+    git add -A
+    no changes against origin/<base> → skip commit and push
+    commit --allow-empty "thirdshift: failed run (<reason>)" + timestamp, host
+    push
+    open PR → gh pr ready --undo, URL on stdout
+    log path on stderr, exit 1
   Drop: remove worktree, local Issue branch, plugin dir
src/
├── failed_run.rs   # FailedRun, the failure commit, push, draft demotion
├── interrupt.rs    # SIGINT/SIGTERM recorded as a flag, not fatal
├── session.rs      # polls claude, stops its process group on interrupt
└── github.rs       # + mark_ready / convert_to_draft

Tested seams: the thirdshift CLI only (tests/failed_run.rs, tests/fresh_run.rs). Tests run the real binary against real git and fake gh/claude, and SIGINT/SIGTERM are sent to the process.

Evidence

  • Before: claude exits 3 with uncommitted work → stderr is only thirdshift: claude exited 3, no failure commit or issue-7 on origin, no log path. A SIGINT during the session → the test waits the full 30s (the session was not stopped). A draft PR from the agent → isDraft stays true.
  • After: cargo test → 17 passed (8 failed_run, 9 fresh_run), including:
    claude exits 3            → origin issue-7: "thirdshift: failed run (claude exited 3)", "Add feature", …; wip.txt pushed
    failure commit body       → "<RFC 3339>, host <h>. Uncommitted work at the time of failure is included in this commit."
    no PR                     → "thirdshift: failed run (no PR found)", stdout empty
    PR base develop           → "(PR targets develop, not main)", PR isDraft=true, PR URL on stdout
    agent exits 1, no changes → no issue-7 on origin
    conflicting merge left    → merge aborted, failure commit on top of "Ours"
    SIGINT / SIGTERM          → session stopped in <1s, "(interrupted)", wip.txt pushed
    draft PR from agent       → isDraft=false, exit 0
    every failure             → exit 1, log path on stderr, no worktree, local branch or temp dir left
    
    cargo fmt --check and cargo clippy --all-targets -D warnings are clean.

Merge Danger

Door: two-way

It adds new behaviour on the failure path; the happy path is unchanged except that a draft PR now gets marked ready. It adds signal-hook and libc dependencies.

Blast Radius: contained

A Failed run now pushes an Issue branch and demotes the PR, which is the behaviour the spec asks for. claude now runs in its own process group, so a terminal Ctrl-C reaches it only through thirdshift.

Unaddressed findings

Standards

  • Data Clumps (implement/fail share issue, worktree, base, log): two private call sites. A context struct would add ceremony before Continuations and Repairs show what a Run context really needs.
  • Primitive Obsession (typed Interrupted error): the reason string is the spec'd output (interrupted). A PR is_open() was added; a full PR state enum is left for when more states matter.
  • Inject the interrupt flag instead of a global: signal state is process-global by nature, and the tests drive real signals through the binary seam.
  • Repeated interrupt::requested() checks: each guards a different window (before the worktree, during the session, after the session, error translation). Merging them would drop one of those windows.

Spec

  • --no-verify on the failure commit: kept on purpose, because a repo hook that rejects the commit would strand the agent's work, which is the one thing the failure path exists to prevent.
  • PR URL on stdout on failure: the acceptance criterion's "nothing on stdout unless a PR exists" allows it, and it keeps stdout meaning "the PR for this run" for scripts.
  • SIGTERM → SIGKILL escalation and a separate process group: needed to actually "stop the session", including the processes claude spawns.
  • "No changes" compares trees, not commits: this matches the spec's "no changes against the Base branch". A commit-then-revert branch has nothing worth pushing.

Closes #5

🤖 Generated with Claude Code

JacobStephens2 and others added 4 commits September 25, 2026 04:41
Any failure after the worktree exists commits the agent's work (aborting an
unfinished merge first), pushes it unless the branch has no changes against
the Base branch, converts an open PR back to a draft, and reports the session
log. SIGINT and SIGTERM stop the session's process group and take the same
path. A draft PR left by the agent is marked ready on success.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…terrupts

Gather the Failed run path into failed_run.rs with a FailedRun type, keep the
failure reason to one line, honour an interrupt that arrives after the
session, replace the draft flag with mark_ready/convert_to_draft, share the
git and gh command plumbing, and assert stdout on every Failed run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in pre-flight checks (#16), the README (#15) and merging the Base
branch with conflict Repair (#17) on top of the Failed run path.

- run.rs: preflight::check replaces the inline origin check; the Repair
  session and final push sit inside implement(), so a failed Repair goes
  down the Failed run path. The session log reported on failure is the
  most recent one (repair-1 if it ran).
- main.rs: parse the Issue URL up front (exit 2) as main does, then
  install the interrupt handler and report a FailedRun.
- github.rs: main's gh_json/string_field helpers, plus isDraft, is_open,
  mark_ready and convert_to_draft.
- failed_run.rs: reuses Worktree::merge_in_progress.
- tests/mergeable.rs: a failed Repair is now a Failed run, so it prints
  the open PR's URL and sends the PR back to draft.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in the progress lines (#18) on top of the Failed run path.

- session.rs: the stream is followed on its own thread, so the main
  thread can still poll for an interrupt and stop claude's process
  group. A follow error kills the group, as main's child.kill() did.
- run.rs: keeps the Failed run structure and adds main's progress steps;
  session::run now takes the session kind.
- main.rs, failed_run.rs: stderr lines go through progress::step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

The Failed run path

1 participant