APPROVALGATED = ("git push", "rm -rf", "sudo ", "git commit --amend", "curl | sh", "ssh ", "scp ")
Root cause. hermes chat -q runs non-interactively. When a terminal command trips the terminal-jail policy, the CLI can't prompt inside the quiet worker, so the approval flow (observed command → Auxiliary approval → cleanup) delivers SIGINT to the worker; the CLI's graceful-cleanup handler exits 130 (128+2), killing the session mid git-diff review. WIP survives in the tree; only session continuation state is lost. Two signals are involved: 130 (shell-visible) and -2 (waitpid/Popen-visible) — the supervisor must classify both as the same abort class.
Fix, layer 1 — worker-side checkpoint before any risky command (the abort fires during the approval flow, so the snapshot must precede the policy gate, not follow it):
# session_hooks.py (excerpt)
APPROVAL_GATED = ("git push", "rm -rf", "sudo ", "git commit --amend", "curl | sh", "ssh ", "scp ")
def run_safely(repo, command, session_id, step, quiet=True):
pre_approval_checkpoint(repo, session_id, step) # 1) snapshot FIRST
install_sigint_manifest_flusher(repo) # 2) flush manifest on SIGINT
if quiet and is_approval_gated(command): # 3) defer, never execute in-band
return 78 # supervisor re-runs post-approval
return subprocess.run(command, shell=True, cwd=repo).returncode
def pre_approval_checkpoint(repo, session_id, step, compile_cmd=None):
manifest = {"session_id": session_id, "cwd": str(repo), "step": step,
"language": "auto", "compile_cmd": compile_cmd or [],
"tree_hash": tree_hash(repo), "attempts": 0, "committed": False}
(repo / ".resume-manifest.json").write_text(json.dumps(manifest, indent=2))
(repo / ".resume-wip.patch").write_text(
subprocess.run(["git","-C",str(repo),"diff","--binary"],
capture_output=True, text=True).stdout) # binary-safe WIP
Fix, layer 2 — supervisor + foreman fallback (runbook automated):
# resume_supervisor.py (excerpt)
def is_approval_abort(rc): # 130 (shell) OR -2 (waitpid) = SIGINT
return rc == 130 or rc == -signal.SIGINT
def supervise(repo, session_id, step, language="auto", max_aborts=2):
manifest = load_manifest(repo) or ResumeManifest(session_id=session_id,
cwd=str(repo), step=step, language=language)
write_manifest(repo, manifest) # baseline
for attempt in range(1, max_aborts + 1):
manifest.attempts = attempt
write_manifest(repo, manifest) # records inherited tree hash
rc = run_worker(repo, manifest, resume=(attempt > 1)) # hermes chat -q --resume
if rc == 0: return {"result": "worker-success", "attempts": ...}
changed = verify_tree(repo) # git status --porcelain, minus .resume-*
ok, msg = compile_check(repo, manifest) # py_compile/go build w/ pycache off-tree
if not is_approval_abort(rc): # non-SIGINT: never auto-commit
return {"result": "hard-failure", "attempts": ...}
if not changed: # worker left nothing; don't spin
return {"result": "no-progress", "attempts": ...}
return foreman_verify_and_commit(repo, manifest) # attempt #2 exhausted
def foreman_verify_and_commit(repo, manifest):
changed = verify_tree(repo)
if not changed: return {"committed": False, "reason": "no changes"}
if drifted(repo, manifest): return {"committed": False, "reason": "tree drifted..."}
ok, msg = compile_check(repo, manifest)
if not ok: return {"committed": False, "reason": f"compile-check failed: {msg}"}
git add -A -- . ':(exclude).resume-*'
git commit -m f"[foreman] resume commit for session {session} step {step} (worker aborted x2, exit 130)"
Key invariants: manifest/patch are .gitignored so they never pollute status or get committed; drift detection compares the current tree hash against the state the attempt inherited (identical-rewrite aborts pass, external concurrent edits block); compile-check never dirties the tree (PYTHONPYCACHEPREFIX to a temp dir).
This host has no live hermes runtime (the shim points at a missing venv) and no code tree, so I built the fix at `/tmp/qa/` and reproduced the exact failure signature with a fake worker (`approval marker → WIP edit → SIGINT cleanup → exit 130`, WIP kept uncommitted), then ran the supervisor against it in fresh scratch git repos. Harness: `run_tests.py`, 36 assertions + 5 extra probes. Verified behaviors (all pass): | # | Scenario | Result | |---|----------|--------| | T1 | worker succeeds (control) | `worker-success`, tree clean, worker's own commit | | T2 | abort once → resume → success | attempt1 exit 130, attempt2 `--resume`, WIP preserved, clean | | T3 | abort twice → foreman | `foreman-commit`; log shows `[foreman] resume commit … (worker aborted x2, exit 130)`; WIP committed; tree clean | | T4 | exit 1 (non-SIGINT) | `hard-failure`, no resume, no commit, WIP kept | | T5 | aborts + broken code | foreman **refuses** (`compile-check failed`), no new commit, tree left for review | | T6 | external drift mid-run | foreman refuses (`tree drifted`), no new commit | | T7 | quiet-mode policy gate | `git push`/`sudo rm` deferred (rc 78), harmless `ls` runs; manifest+patch checkpointed, gitignored | | T8 | SIGINT handler | manifest flushed with `aborted_by: signal-2`, killed by SIGINT (130/-2 both classified) | | probe | Go repo, `--language go`, abort twice | `go build ./…` gate passes → `foreman-commit` | Edge cases explicitly tested: 130 vs -2 classification, identical-rewrite aborts (not misread as drift), external drift (blocked), compile failure (blocked), empty-WIP abort (no-progress, no spin), checkpoint files never entering status or commits, compile-check leaving the tree byte-identical. Exit-code semantics confirmed: `130 == 128 + SIGINT(2)`.
{"model": "deepseek-v4-flash", "problem_class": "worker-session-quiet-mode-approval-abort", "result": "passed", "tests": 41}