Skip to content

[Bugfix #1264] A clean harness exit reruns fresh instead of killing the session#1266

Merged
waleedkadous merged 7 commits into
mainfrom
builder/bugfix-1264
Jul 27, 2026
Merged

[Bugfix #1264] A clean harness exit reruns fresh instead of killing the session#1266
waleedkadous merged 7 commits into
mainfrom
builder/bugfix-1264

Conversation

@waleedkadous

@waleedkadous waleedkadous commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Reworked 2026-07-27 after a spec correction from the architect (comment on #1264). The first implementation discriminated ^C^C from a typed quit by sniffing 0x03 in the shellper's input stream; it was reviewed (CMAP below) and one real defect fixed. The corrected spec makes that whole approach unnecessary. The branch was force-pushed to carry only the new design — the superseded commits (ed10e2fc3d525114) remain reachable via the force-push record.

Summary

Since v3.2.4, killing an agent with double Ctrl-C left it dead and needed a manual respawn. The fix is not to tell ^C^C apart from a typed quit — the corrected framing is that a clean harness exit should never kill the shellper at all.

Fixes #1264

Root Cause

#1241 made any clean exit end the session, reading a quit as a decision that must not be overridden. Probing the real CLI shows why discrimination was a dead end anyway — Claude CLI 2.1.220 under node-pty:

Gesture node-pty exit
^C^C {exitCode: 0, signal: 0}
typed /quit {exitCode: 0, signal: 0}

Byte-identical. The corrected spec removes the need to distinguish them.

Scope note: restartOnExit is set only for architect sessions (tower-instances.ts ×2, tower-terminals.ts ×1), so this is the surface that actually died. Builders run the harness inside a bash loop and are out of scope by architect decision — tracked in #1267.

Fix

  • Any clean exit → shellper and session survive; harness reruns in the same PTY without recovery (no --resume; fresh conversation).
  • Unnatural exits (crash, signal, nonzero) → unchanged, restart with recovery.
  • A session ends only via explicit kill (afx / UI / DELETE).
File Change
session-manager.ts Clean exit reruns via a caller-supplied FreshLaunch factory instead of ending the session; not counted as a restart.
pty-session.ts Clean exit takes the restart-wait path (session survives), sharing an extracted startRestartWait with the crash path — differing only in the notice.
tower-utils.ts buildArchitectFreshLaunch — mints a new conversation id, re-injects the role, persists the id to the architect row.
tower-instances.ts ×2, tower-terminals.ts ×1 Wire the factory.

Two things that are load-bearing rather than incidental:

FreshLaunch is a factory, not a precomputed arg list. Every clean exit is a genuinely new conversation and needs its own minted id. Reusing one set of args would make the second ^C^C collide with the session id the first one just created.

The new id is persisted. Otherwise an unnatural exit after a clean rerun would resume the conversation the user just deliberately walked away from. All three wiring sites build the factory from baseArgs/cmdParts, never from the resolved args — those may already carry --resume, which would defeat the whole point.

Design note: the fast-clean-exit valve (architect-approved, first-class)

Making clean-exit reruns unlimited is correct — quitting is a gesture, not a failure — but it removes a bound that both prior versions had (#1241 ended the session; pre-3.2.4 had maxRestarts). A harness misconfigured to exit 0 on startup would then respawn forever.

So: a clean exit within 2s of launch is evidence of a broken command, not a human gesture — 5 consecutive such exits give up. A real quit never trips it, and one fast exit followed by a healthy session resets the counter. Genuine gestures stay unlimited.

The give-up is loud by design: a new session-gave-up event carries a plain-language reason that Tower writes into the terminal (PtySession.notice) as well as the log, so a user with a misconfigured command sees why their pane went quiet instead of meeting a mystery dead session. This deliberately does not reuse session-error, which also fires for transient client errors that do not end the session — surfacing that as a teardown notice would lie.

Known gap, not addressed here: maxRestarts exhaustion has the identical silent-death UX and does not get this treatment. Out of scope for this fix; flagging it as a follow-up candidate rather than quietly widening the diff.

Test Plan

End-to-end — real SessionManager → real shellper process → real claude, asserting on the argv actually relaunched:

Gesture Events Relaunch argv
^C^C fresh-restart, session live, agent back up new --session-id, no --resume, factory called 1× PASS
SIGKILL restart#1, session live, agent back up argv unchanged (recovery preserved), factory called 0× PASS

Unit (replacing the #1241 matrix, which asserted the behavior this reverses):

  • session survives a clean exit; no session-clean-exit
  • relaunch carries no --resume and none of the old conversation id
  • a new conversation per rerun, never reusing the first
  • restartCount and crash-loop history untouched across repeated clean exits
  • no-factory sessions (plain shells) still rerun, args unchanged
  • crash and signal death restart with recovery, factory never consulted
  • PtySession suppresses exit and awaits the rerun; bounded wait still tears down if none arrives
  • the real-shellper exit 0 case now asserts the fast-clean-exit valve, and still never applies crashLoopFallback (Tower recovery: Claude architect crash-loops on stale --resume with no user escape #1149)

Full suite: 3759 passed, 0 failed, 48 skipped (194 files); build green.

CMAP on the superseded design (kept for the record)

gemini APPROVE · claude APPROVE · codex REQUEST_CHANGES — codex correctly found that Ctrl-D after a Ctrl-C would have restarted an EOF quit; fixed before the rework. Two approvals did not outvote one specific, reproducible finding. Re-CMAP on this diff is running.

  • the fast-clean-exit valve: gives up after 5 immediate clean exits with a reason naming the behavior and saying respawning stopped; one healthy session resets the counter; real (slow) quits never trip it at 2× the threshold
  • freshLaunch survives reconnectSession — a post-Tower-restart clean exit still relaunches fresh, driven through the real reconnect path over a real shellper socket (CMAP finding, see below)

CMAP

Round 1 (superseded 0x03 design) — gemini APPROVE · claude APPROVE · codex REQUEST_CHANGES: Ctrl-D after a Ctrl-C would have restarted an EOF quit. Fixed before the rework.

Round 2 (this design) — gemini APPROVE · claude APPROVE · codex REQUEST_CHANGES:

reconnectSession() accepts restartOptions.freshLaunch in its type, but never copies it into session.options.

Correct, and the most consequential finding in either round. A reconnected architect's args already carry --resume, so after any Tower restart (i.e. every local-install) a clean exit would have relaunched into the conversation the user just quit — the headline guarantee silently broken on the most common surface, with every fresh-path test still passing. Fixed in 4b7db2d6, with the regression test codex noted was missing.

My first attempt at that test asserted on a hand-built object literal and never called reconnectSession — it would not have caught the bug it was written for. Rewritten against a real ShellperProcess over a real socket.

Two rounds, two APPROVEs each sitting alongside one specific reproducible codex finding, and both findings real.

Incidental discovery while fixing it: several pre-existing reconnect tests guard with if (client) and pass Date.now() as the process start time, which never matches the real one — so those test bodies silently never execute. Not touched here; worth its own issue.

@waleedkadous

Copy link
Copy Markdown
Contributor Author

CMAP review

Model Verdict
gemini APPROVE — "no issues"
claude APPROVE — "no issues"
codex REQUEST_CHANGES — addressed in 3d52511

Codex's finding — real, and worth the round trip

In raw mode, a deliberate EOF exit (Ctrl-D, 0x04) after using Ctrl-C to interrupt work within the 10s window will still be marked ctrlC: true.

Correct. My clear-the-stamp rule was "clears on printable input, CR, or LF", and 0x04 is none of those. Reproduced the rule in isolation before changing anything:

after ^C then ^D, ctrlC stamp set = true  -> exit would be flagged ctrlC and RESTART

The mistake was reasoning about printability when the actual question is recency — a quit gesture doesn't have to be printable.

Fix

The rule collapses to its correct form: the stamp survives iff the most recent input byte is 0x03.

private noteInput(payload: Buffer): void {
  if (payload.length === 0) return;
  this.lastCtrlCAt = payload[payload.length - 1] === CTRL_C_BYTE ? Date.now() : null;
}

Simpler and strictly more correct — the isRealInput helper and the lastIndexOf/subarray scan both disappear (net −11 lines). Every previously passing case still holds, because the post-chunk state only ever depended on the last byte anyway.

Added regression coverage for ^C^D, and for ^C superseded by Ctrl-Z / Tab / bare Escape.

Re-verified end-to-end after the change (real SessionManager → real shellper → real claude): ^C^C still restarts, /quit still ends cleanly. Full suite 3769 passed, 0 failed.

On the split verdict

Two APPROVEs with "no issues" didn't outvote one specific, reproducible finding — codex was the only reviewer that traced the byte-level rule against a gesture the tests didn't cover, which is the same class of gap (an uncovered recovery gesture) that let #1264 ship in the first place.

…ing the session

Since v3.2.4 a double Ctrl-C left the agent dead, needing a manual respawn.
#1241 had made any clean exit end the session, on the reading that a quit is
the user's decision and must not be overridden.

The corrected framing (architect, on the issue): the problem is not telling
^C^C apart from a typed quit — they are genuinely indistinguishable, both
exiting {code: 0, signal: 0} on Claude CLI 2.1.220 — it is that a clean exit
kills the shellper at all. So no discrimination is needed:

- ANY clean exit: the shellper and session survive; the harness is rerun in
  the same PTY WITHOUT recovery — a fresh conversation, since the user just
  deliberately left the old one.
- Unnatural exits (crash, signal, nonzero): unchanged, restart WITH recovery.
- A session ends only via an explicit kill (afx / UI / DELETE).

SessionManager cannot build recovery-free args itself — it holds only the argv
it was launched with, which may already carry --resume. So callers supply a
FreshLaunch factory. A factory, not a precomputed arg list: every clean exit
is a genuinely new conversation and needs its own minted id.
buildArchitectFreshLaunch mints it, re-injects the role (which the resume path
deliberately skips), and PERSISTS it to the architect row — otherwise a later
crash would resume the conversation the user just walked away from. All three
architect wiring sites build it from baseArgs, never from the resolved args,
so a --resume can never leak into the rerun.

PtySession now takes the same restart-wait path for clean exits as for
crashes — session kept alive, 'exit' suppressed so Tower keeps the architect
row and its clients — sharing one extracted startRestartWait that differs
only in the notice, since 'restarting' would misdescribe getting a NEW
conversation back.

Clean-exit reruns do not touch restartCount or the crash-loop history: a user
quitting their agent is a gesture, not a failure, and a long-lived architect
can out-quit any budget.

Safety valve (beyond the prescribed design, flagged in the PR): unlimited
reruns would remove a bound BOTH prior versions had (#1241 ended the session;
pre-3.2.4 had maxRestarts), letting a harness misconfigured to exit 0 on
startup respawn forever. A clean exit within 2s of launch is a broken command
rather than a human gesture, so 5 consecutive such exits give up. A real quit
never trips it; one fast exit followed by a healthy session resets the count.

Builders are out of scope by architect decision — their bash launch loop is
tracked separately in #1267.
…l resume

Replaces the #1241 matrix, which asserted the behavior this issue reverses.

session-manager:
- a clean exit reruns the harness and the session STAYS (no session-clean-exit)
- the relaunch argv carries no --resume and none of the old conversation id —
  the headline guarantee
- every clean exit mints a NEW conversation, never reusing the first
- restartCount and the crash-loop history stay untouched across repeated clean
  exits (the budget must not be consumed by gestures)
- no fresh-launch factory (plain shells) still reruns, with args unchanged
- signal death and crash restart WITH recovery: argv preserved and the factory
  never consulted

The real-shellper 'exit 0' test now asserts the fast-clean-exit valve instead
of the old end-the-session behavior: that command exits cleanly the instant it
launches, which is a broken harness rather than a user gesture, so it is
bounded and given up — and still never applies the crashLoopFallback (#1149).

tower-shellper-integration: a clean exit suppresses 'exit' and awaits the
rerun (emitting it would clear the architect row — the #1264 symptom), shows a
notice that does not promise the old conversation back, and the bounded wait
still tears down if no rerun ever arrives.
@waleedkadous
waleedkadous force-pushed the builder/bugfix-1264 branch from e57ea6a to 37fe551 Compare July 27, 2026 05:36
@waleedkadous waleedkadous changed the title [Bugfix #1264] Double Ctrl-C restarts the agent again; typed quit still ends it [Bugfix #1264] A clean harness exit reruns fresh instead of killing the session Jul 27, 2026
…-up loud

Two review findings plus the architect's ruling on the fast-exit valve.

1. CMAP (codex): reconnectSession accepted freshLaunch in its options type but
   never copied it onto the session. Worse than it sounds — a reconnected
   architect's args already carry --resume (baked by the #832 restart
   resolution), so after ANY Tower restart a clean exit would have relaunched
   straight back into the conversation the user just quit. The headline
   guarantee, silently broken on the most common surface, with every
   fresh-path test still green. Fixed, with a regression test that drives the
   real reconnectSession over a real shellper socket.

   (My first attempt at that test asserted on a hand-built object literal and
   never called reconnectSession — it would not have caught the bug it was
   written for. Rewritten.)

2. Architect ruled KEEP on the fast-clean-exit valve, requiring the give-up be
   loud. A new 'session-gave-up' event carries a plain-language reason that
   Tower writes into the terminal itself via PtySession.notice, not just the
   log: someone with a misconfigured command needs to see why their pane went
   quiet rather than meeting a mystery dead session. Deliberately NOT reusing
   'session-error' — that also fires for transient client errors that do not
   end the session, so surfacing it as a teardown notice would be wrong.

   maxRestarts exhaustion has the same silent-death UX and is NOT covered
   here; noted in the PR as a follow-up rather than quietly widening scope.

3. Valve test coverage per the architect: gives up after 5 fast exits with a
   reason naming the behavior and saying respawning stopped; one healthy
   session resets the counter; real (slow) quits never trip it even at twice
   the threshold.
@waleedkadous

Copy link
Copy Markdown
Contributor Author

CMAP round 2 (reworked design)

Model Verdict
gemini APPROVE — "no issues"
claude APPROVE — "no issues"
codex REQUEST_CHANGES — addressed in 4b7db2d6

Codex's finding — the most consequential of either round

reconnectSession() accepts restartOptions.freshLaunch in its type, but never copies it into session.options.

Verified against the source: the reconnect path assembled options field-by-field and simply omitted the new one.

The impact is worse than the phrasing suggests. A reconnected architect's args already carry --resume (baked by the #832 restart resolution), so after any Tower restart — which is every local-install — a clean exit would have relaunched straight back into the conversation the user had just quit. That is the headline guarantee of this PR, silently broken on the most common surface in the system, while every fresh-path test stayed green.

Fixed, plus the regression test codex correctly noted was missing.

My first attempt at that test was bad and I want it on the record: it asserted on a hand-built object literal and never called reconnectSession at all — it would have passed against the buggy code. Rewritten to drive the real reconnectSession over a real ShellperProcess socket, then confirmed it fails without the one-line fix.

Also in this commit — the architect's ruling on the fast-exit valve

Keep it, with the give-up made loud: a new session-gave-up event carries a plain-language reason that Tower writes into the terminal (PtySession.notice) as well as the log. Deliberately not session-error, which also fires for transient client errors that don't end the session. Valve test coverage added per the same ruling. Both are now first-class in the PR body's design notes rather than a footnote.

Verification after the fixes

  • E2E on the real stack: ^C^C → new --session-id, no --resume, session live, agent back. SIGKILL → argv unchanged, recovery preserved.
  • Full suite 3759 passed, 0 failed, 48 skipped; build green.

Incidental discovery

Several pre-existing reconnect tests guard with if (client) and pass Date.now() as the process start time, which never matches the real one — so those test bodies silently never execute. Untouched here; worth its own issue.

Note on the split verdicts

Two rounds, and in both a pair of APPROVEs sat alongside a single specific, reproducible codex finding — both of which were real defects. Worth remembering when weighing a 2-1 CMAP.

@waleedkadous
waleedkadous merged commit 3c82238 into main Jul 27, 2026
6 checks passed
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.

Regression (3.2.4): double Ctrl-C on an agent no longer restarts it fresh — classified as deliberate quit

1 participant