Skip to content

kanban: make the survivor escape hatch reachable from the kanban_complete TOOL (t_52abef50) - #839

Merged
Kyzcreig merged 2 commits into
mainfrom
kanban/t_52abef50-tool-survivor
Sep 22, 2026
Merged

Kyzcreig merged 2 commits into
mainfrom
kanban/t_52abef50-tool-survivor

Conversation

@Kyzcreig

@Kyzcreig Kyzcreig commented Sep 21, 2026 •

Copy link
Copy Markdown
Collaborator

The gap

preserve() refuses a completion whose implementation it cannot find and points at --survivor-pr / --survivor-ref. #837 restored that remedy on the CLI and the library. It was still absent from the TOOL — the agent-facing surface where the refusal is actually read.

Argus measured it on #837 head a26f2700bd: 0 occurrences of "survivor" in tools/kanban_tools.py. A kanban worker hit survivor_unavailable: ... use --survivor-pr <owner/repo#N> and the error named a remedy the worker had no way to invoke.

This PR adds survivor_pr / survivor_ref to the kanban_complete schema and threads them to kb.complete_task(...), which already accepted both.

Stacked on #837 — this state is only reachable with its stale-bases fix. Merge #837 first.

Design call: worker-reachable, not operator-only

The card asked me to decide explicitly whether a worker should be able to name a survivor at all, since self-certifying weakens the guard protecting unpushed work.

I made it worker-reachable, not on ergonomics — on a measured fact: a worker can already reach --survivor-pr today by shelling out to hermes kanban complete. I measured that path taking effect:

owns_kanban_worker_authority = False
_worker_run_id_for           = None      <-- the guard is SKIPPED
CLI rc = 0
status = done
survivor = {"kind": "ref", "refs": [{... "pr": "example/project#68", "state": "MERGED"}]}

The shelled-out subprocess does not own the run, so it silently skips the expected_run_id optimistic-concurrency guard that keeps a stale writer from closing a live card. "Operator-only" was never enforced — it was only inconvenient, and the inconvenient path is the weaker one. The tool argument is strictly safer: it runs inside the owning process, so ownership and expected_run_id both apply.

Verification semantics are unchanged

Both arguments land on the same _verified_explicit() call the flag uses:

  • an operator-named survivor is still remote-VERIFIED;
  • an unverifiable claim still refuses;
  • text mining still cannot reach the relaxed branch — test 3 pins this: the same PR named in the summary instead of the argument still fails closed.

A non-string argument is rejected before the verifier, so a malformed claim never reaches the remote as junk. Redaction is inherited from the same _ext.redact() path; test 4 pins that a survivor_ref carrying oauth2:ghp_...@ is redacted in the tool's returned error and absent from held_reason and the event log.

Verified

  • tests/tools/test_kanban_tool_survivor.py — 6 passed, stable 5/5 consecutive runs.
  • Mutation (drop the threading, keep the schema): 2 failed, 4 passed. The tests have teeth.
  • E2E from inside a real worker run — the proof Argus's probe could not get (its non-owning process was correctly refused by the ownership guard). With owns_kanban_worker_authority=True against a sandboxed board:
--- 1. NO survivor arg (the dead-end a worker hits today) ---
{"error": "kanban_complete: survivor_unavailable: recorded repository missing; use --survivor-pr ..."}

--- 2. WITH survivor_pr (the remedy, now reachable from the TOOL) ---
{"ok": true, "task_id": "t_2828cdde", "run_id": 1}

status   = done
survivor = {"kind": "ref", "refs": [{"pr": "example/project#68", "state": "MERGED", ...}]}
gh calls = [['gh', 'pr', 'view', '68', '--repo', 'example/project', ...]]

The gh call proves the tool argument is verified against the remote, not taken on trust.

  • Argus's exact measurement, re-run: TOOL_ACCEPTS_SURVIVOR_PR = True, TOOL_ACCEPTS_SURVIVOR_REF = True, 22 occurrences of "survivor".
  • Cohort (95 tests): flaky in this environment — wandering failures across runs in tests this diff does not touch. I reproduced the same wandering flake on base with the change stashed (3 runs, 3 different failures), so it is inherited, not mine.

Scope / provenance

  • Fork-only. Upstream has tools/kanban_tools.py but no kanban_survivor.py and no survivor params on complete_task — there is no upstream surface to port to. No upstream PR.
  • hermes_cli/kanban.py NOT touched despite the dispatch-collision warning with t_f08b7589. The threading needed nothing there.

Incident disclosed

My first shell-out probe leaked a card (t_4a83f71f) onto the live board: HERMES_KANBAN_DB outranks HERMES_HOME, so redirecting HERMES_HOME alone does not sandbox kanban. The runtime warned, but I had already written. Fully reverted (tasks / task_runs / task_events / task_workspace_survivors; no attachments existed), live board re-verified clean. Every subsequent probe sets HERMES_KANBAN_SANDBOX=1, unsets the HERMES_KANBAN_* pins, and asserts the resolved DB path is under its tmpdir before writing.

Card: t_52abef50 (derived from t_49c1dec1)


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

…ishes

`preserve()` records `bases` at dispatch from the git checkout then in the
workspace. When that checkout is later gone but the workspace DIRECTORY
survives (evidence, logs, qa-output), the recorded-repository guard raised
unconditionally -- on the branch where `explicit` is never consulted. The
`--survivor-pr` / `--survivor-ref` remedy the error names is only read on the
workspace-MISSING branch, so that state had no reachable remedy at all: a
reviewer who edited nothing and had nothing to preserve could not complete,
even with a valid remote-verified PR.

Consult `explicit` before that raise. With no operator survivor the guard is
unchanged and still fails closed -- that is what protects unpushed work. The
relaxation is for OPERATOR flags only; a mined hint cannot reach it.

Two things the first cut got wrong, both found by mutation and fixed:

- `claimed = True` is required, or a verified survivor is accepted but never
  recorded.
- On a PARTIAL loss (some recorded repos survive, one vanished), the survivors
  of the rest satisfied the completion on their own and silently dropped the
  operator's ref for the lost repo. `recovered` seeds it into `refs`, and the
  ref-kind count accounts for it -- without that arithmetic the card falls
  through to `_external` and records a DUPLICATE ref under repository ".".

Verified:
- 7 new tests in tests/hermes_cli/test_kanban_survivor_stale_bases.py.
- 5/5 mutations killed (revert the fix; drop the guard; drop `claimed = True`;
  drop `recovered` seeding; revert the ref-count arithmetic). Source restored
  and sha256-compared byte-identical after the sweep.
- 959 passed / 0 failed / 3 skipped across all 81 tests/hermes_cli kanban files.
- ruff 0.15.20 clean; `git diff --check` clean.
- Live proof on an isolated HERMES_KANBAN_SANDBOX=1 board through the real CLI:
  refusal -> exit 1 + reason + remedy, card left `ready`; a real remote-verified
  `--survivor-pr #835` -> exit 0, "Completed <id>",
  survivor recorded at b4707a3; a nonexistent PR -> exit 1, still refused.

NOT DONE, and deliberately: the card's item 3 asks to fix `hermes kanban
complete` exiting 0 on this refusal. That does not reproduce on this tree. A
raised SurvivorUnavailable is a ValueError and kanban.py already catches it,
prints `kanban: <reason>` to stderr, and returns 1; main.py propagates a nonzero
handler return via sys.exit. Measured exit 1 on fork/main BEFORE this change,
through both `python -m hermes_cli.main` and the deployed `hermes` wrapper. What
was genuinely missing was the remedy text -- the message named no way out -- so
the HINT is now appended, and two CLI-level tests pin exit-nonzero-with-hint and
exit-zero-on-success. The card's item 4 (should a reviewer-lane completion
consult `bases` at all) is a contract question left open, not silently decided.
…lete TOOL

`preserve()` refuses a completion it cannot find an implementation for and
points at `--survivor-pr` / `--survivor-ref`. #837 restored that remedy on the
CLI and the library. It was still absent from the TOOL -- the agent-facing
surface where the refusal is actually READ. Argus measured it on #837 head
a26f270: 0 occurrences of "survivor" in tools/kanban_tools.py. A worker hit
a hint naming a flag it had no way to invoke.

Add `survivor_pr` / `survivor_ref` to the schema and thread them to
`kb.complete_task(...)`, which already accepted both.

DESIGN CALL (the card asked for it explicitly): worker-reachable, not
operator-only. Not on ergonomics -- on a MEASURED fact. A worker can already
reach `--survivor-pr` today by shelling out to `hermes kanban complete`, and I
measured that path taking effect: rc=0, status=done, survivor recorded, with
`_worker_run_id_for()` returning None because the shelled-out subprocess does
not own the run. So the shell-out ALREADY skips the `expected_run_id`
optimistic-concurrency guard that keeps a stale writer from closing a live
card. "Operator-only" was never enforced -- it was only inconvenient, and the
inconvenient path is the weaker one. The tool argument is strictly safer: it
runs inside the owning process, so ownership and expected_run_id both apply.

The verification semantics are unchanged because both arguments land on the
same `_verified_explicit()` call the flag uses:
  - an operator-named survivor is still remote-VERIFIED,
  - an unverifiable claim still refuses,
  - text mining still cannot reach the relaxed branch (test 3 pins this: the
    same PR named in the SUMMARY instead of the argument still fails closed).
A non-string argument is rejected before the verifier, so a malformed claim
cannot reach the remote as junk. Redaction is inherited from the same
`_ext.redact()` path -- test 4 pins that a `survivor_ref` carrying
`oauth2:ghp_...@` is redacted in the tool's returned error and absent from
held_reason and the event log.

Stacked on #837 (this state is only reachable with its stale-bases fix).
Fork-only: upstream has tools/kanban_tools.py but no kanban_survivor.py and no
survivor params on complete_task, so there is no upstream surface to port to.

VERIFIED
- tests/tools/test_kanban_tool_survivor.py: 6 passed, 5/5 consecutive runs.
- mutation (drop the threading): 2 failed, 4 passed -- the tests have teeth.
- E2E from inside a REAL worker run, which Argus's probe could not do (its
  non-owning process was correctly refused by the ownership guard). With
  owns_kanban_worker_authority=True against a sandboxed board: no argument ->
  `survivor_unavailable: recorded repository missing; use --survivor-pr ...`;
  with survivor_pr -> {"ok": true}, status=done, survivor recorded as the
  verified external ref, and one real `gh pr view` consulted.
- Argus's measurement re-run: TOOL_ACCEPTS_SURVIVOR_PR/REF now both True.
- Cohort (95 tests incl. test_kanban_redaction, test_kanban_tools, the three
  survivor files): flaky in this environment, wandering failures across runs in
  tests this diff does not touch. Reproduced the SAME wandering flake on base
  with the change stashed (3 runs, 3 different failures), so it is inherited,
  not mine. My file is stable 5/5.

Incident disclosed for the record: my first shell-out probe leaked a card
(t_4a83f71f) onto the LIVE board -- HERMES_KANBAN_DB outranks HERMES_HOME, so
redirecting HERMES_HOME alone does NOT sandbox kanban (the runtime warned; I
had already written). Fully reverted (tasks/task_runs/task_events/
task_workspace_survivors, no attachments); live board re-verified clean. Every
subsequent probe sets HERMES_KANBAN_SANDBOX=1, unsets the HERMES_KANBAN_* pins,
and asserts the resolved DB path is under its tmpdir before writing.
@Kyzcreig

Copy link
Copy Markdown
Collaborator Author

FleetReview

Reviewed with 2 of 3 model families — openai unavailable.

Confidence: 3/5

Findings

  • P1 tools/kanban_tools.py:825 — Worker-supplied survivor_pr/survivor_ref is treated as operator authority and immediately authorizes rmtree of uncaptured work

FleetReview provenance · models: C=claude-code-opus-5, D=grok-4.6, G=grok-4.6 · cost: $4.83 · duration: 48m 47s · rounds: 2 · files examined: 2

Base automatically changed from daedalus-opus/t_49c1dec1-survivor-escape to main September 22, 2026 01:50
Kyzcreig added a commit that referenced this pull request Sep 22, 2026
An operator- or worker-named --survivor-pr/--survivor-ref was verified only
as "this PR exists on GitHub and is OPEN or MERGED". _verified_explicit()
called verify_pr()/verify_ref() with mined_for UNSET, so the one check that
ties a PR to a card never ran on the explicit path. preserve() then treats a
verified explicit survivor as authority for the stale-bases branch -- the
branch whose whole job is protecting UNPUSHED implementation work -- so any
live PR authorised deleting a workspace whose bytes may exist nowhere else.

Measured on fork/main with real gh, two PRs unrelated to the probe card:
  NousResearch#1     explicit -> ACCEPTED MERGED; mined -> REFUSED
  #837   explicit -> ACCEPTED OPEN;   mined -> REFUSED
After this change both are REFUSED on the explicit path and ACCEPTED only
under the new override.

Remedy (a) with an explicit override. The claim now carries the same task-id
binding the mined path carries. The legitimate operator case -- a human who
knows the work landed on a differently-named branch -- keeps a reachable path
via --survivor-unbound, which is recorded on the survivor (unbound=True,
claimed_by=<OS user>) and replayed into the task event log, so the
authorisation is auditable rather than invisible. The override is a CLI flag
only: no kanban_complete tool argument can express it, so a worker on the
#839 tool surface cannot self-certify an unrelated survivor.

The explicit path corroborates on headRefName/title/body -- the PR's own
claim about which card it implements, from a caller who already vouched for
the PR's identity. The mined path stays branch-only via the `corroborate`
default: widening it would let a PR body that merely mentions a card id
verify itself out of handoff text.

Verified: 79 passed / 0 failed across all four survivor files (fork/main
baseline measured 65 in a clean-room worktree; +14 new). Mutations, each run
and reverted: drop the binding -> 3 red; widen the mined path -> 1 red; kill
the override -> 2 red. Disjoint from #837/#839/#842/#796 -- none of them
touches _verified_explicit.
@Kyzcreig
Kyzcreig added this pull request to the merge queue Sep 22, 2026
Merged via the queue into main with commit 133a843 Sep 22, 2026
96 of 98 checks passed
@Kyzcreig
Kyzcreig deleted the kanban/t_52abef50-tool-survivor branch September 22, 2026 07:57
Kyzcreig added a commit that referenced this pull request Sep 22, 2026
An operator- or worker-named --survivor-pr/--survivor-ref was verified only
as "this PR exists on GitHub and is OPEN or MERGED". _verified_explicit()
called verify_pr()/verify_ref() with mined_for UNSET, so the one check that
ties a PR to a card never ran on the explicit path. preserve() then treats a
verified explicit survivor as authority for the stale-bases branch -- the
branch whose whole job is protecting UNPUSHED implementation work -- so any
live PR authorised deleting a workspace whose bytes may exist nowhere else.

Measured on fork/main with real gh, two PRs unrelated to the probe card:
  NousResearch#1     explicit -> ACCEPTED MERGED; mined -> REFUSED
  #837   explicit -> ACCEPTED OPEN;   mined -> REFUSED
After this change both are REFUSED on the explicit path and ACCEPTED only
under the new override.

Remedy (a) with an explicit override. The claim now carries the same task-id
binding the mined path carries. The legitimate operator case -- a human who
knows the work landed on a differently-named branch -- keeps a reachable path
via --survivor-unbound, which is recorded on the survivor (unbound=True,
claimed_by=<OS user>) and replayed into the task event log, so the
authorisation is auditable rather than invisible. The override is a CLI flag
only: no kanban_complete tool argument can express it, so a worker on the
#839 tool surface cannot self-certify an unrelated survivor.

The explicit path corroborates on headRefName/title/body -- the PR's own
claim about which card it implements, from a caller who already vouched for
the PR's identity. The mined path stays branch-only via the `corroborate`
default: widening it would let a PR body that merely mentions a card id
verify itself out of handoff text.

Verified: 79 passed / 0 failed across all four survivor files (fork/main
baseline measured 65 in a clean-room worktree; +14 new). Mutations, each run
and reverted: drop the binding -> 3 red; widen the mined path -> 1 red; kill
the override -> 2 red. Disjoint from #837/#839/#842/#796 -- none of them
touches _verified_explicit.
Kyzcreig added a commit that referenced this pull request Sep 22, 2026
…ding

#839 (tool-reachable survivor_pr) landed on main mid-run. Its fixture PR is
live and MERGED but its head branch names no card, so the binding this
branch adds correctly REFUSES it -- caught as the only head-vs-base delta in
the -k kanban sweep (33 head / 32 base, one name).

The gate is right, the fixture was not: name the card for the happy path,
exactly as tests/hermes_cli/test_kanban_survivor_stale_bases.py does.

Also lands Argus's r2 carry-forward. test_the_tool_surface_cannot_express_
the_override is a SOURCE GREP and structurally cannot catch a runtime kwarg,
so add the RUNTIME arms now that survivor_pr is really on the tool:
  - a live PR that does not name the card -> refused, workspace bytes intact
  - 4 smuggling spellings of the CLI-only override (survivor_unbound,
    unbound, string "1", metadata-nested) -> all refused, bytes intact

Measured: 11 passed. Mutation, applied then reverted: drop the binding
(mined_for unset on the explicit path) -> 5 red, all five of the new arms.
Kyzcreig added a commit that referenced this pull request Sep 22, 2026
An operator- or worker-named --survivor-pr/--survivor-ref was verified only
as "this PR exists on GitHub and is OPEN or MERGED". _verified_explicit()
called verify_pr()/verify_ref() with mined_for UNSET, so the one check that
ties a PR to a card never ran on the explicit path. preserve() then treats a
verified explicit survivor as authority for the stale-bases branch -- the
branch whose whole job is protecting UNPUSHED implementation work -- so any
live PR authorised deleting a workspace whose bytes may exist nowhere else.

Measured on fork/main with real gh, two PRs unrelated to the probe card:
  NousResearch#1     explicit -> ACCEPTED MERGED; mined -> REFUSED
  #837   explicit -> ACCEPTED OPEN;   mined -> REFUSED
After this change both are REFUSED on the explicit path and ACCEPTED only
under the new override.

Remedy (a) with an explicit override. The claim now carries the same task-id
binding the mined path carries. The legitimate operator case -- a human who
knows the work landed on a differently-named branch -- keeps a reachable path
via --survivor-unbound, which is recorded on the survivor (unbound=True,
claimed_by=<OS user>) and replayed into the task event log, so the
authorisation is auditable rather than invisible. The override is a CLI flag
only: no kanban_complete tool argument can express it, so a worker on the
#839 tool surface cannot self-certify an unrelated survivor.

The explicit path corroborates on headRefName/title/body -- the PR's own
claim about which card it implements, from a caller who already vouched for
the PR's identity. The mined path stays branch-only via the `corroborate`
default: widening it would let a PR body that merely mentions a card id
verify itself out of handoff text.

Verified: 79 passed / 0 failed across all four survivor files (fork/main
baseline measured 65 in a clean-room worktree; +14 new). Mutations, each run
and reverted: drop the binding -> 3 red; widen the mined path -> 1 red; kill
the override -> 2 red. Disjoint from #837/#839/#842/#796 -- none of them
touches _verified_explicit.
Kyzcreig added a commit that referenced this pull request Sep 22, 2026
…ding

#839 (tool-reachable survivor_pr) landed on main mid-run. Its fixture PR is
live and MERGED but its head branch names no card, so the binding this
branch adds correctly REFUSES it -- caught as the only head-vs-base delta in
the -k kanban sweep (33 head / 32 base, one name).

The gate is right, the fixture was not: name the card for the happy path,
exactly as tests/hermes_cli/test_kanban_survivor_stale_bases.py does.

Also lands Argus's r2 carry-forward. test_the_tool_surface_cannot_express_
the_override is a SOURCE GREP and structurally cannot catch a runtime kwarg,
so add the RUNTIME arms now that survivor_pr is really on the tool:
  - a live PR that does not name the card -> refused, workspace bytes intact
  - 4 smuggling spellings of the CLI-only override (survivor_unbound,
    unbound, string "1", metadata-nested) -> all refused, bytes intact

Measured: 11 passed. Mutation, applied then reverted: drop the binding
(mined_for unset on the explicit path) -> 5 red, all five of the new arms.
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