Skip to content

fix(kanban): bind an explicit survivor claim to the card that names it - #848

Merged
Kyzcreig merged 8 commits into
mainfrom
daedalus-opus/t_de2e348e-survivor-claim-binding
Sep 22, 2026
Merged

Kyzcreig merged 8 commits into
mainfrom
daedalus-opus/t_de2e348e-survivor-claim-binding

Conversation

@Kyzcreig

@Kyzcreig Kyzcreig commented Sep 21, 2026 •

Copy link
Copy Markdown
Collaborator

Closes kanban card t_de2e348e (found by Argus reviewing #839).

The gap

_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
operator/worker-named path. The claim was verified only as "this PR exists on
GitHub and is OPEN or MERGED"
. 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.

Pre-existing, not introduced by #837 (Argus measured it bit-identical on
a3e3c169b9, before #837). #837/#839 widen who can name a survivor; this PR
fixes the lock rather than the door.

Decision: (a), with an explicit override

The explicit 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,
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. test_the_tool_surface_cannot_express_the_override pins
that by construction (schema + handler source), not by convention.

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.

Measured, real gh, the exact two PRs Argus measured as ACCEPTED

NousResearch/hermes-agent#1
   bound (default explicit path) -> REFUSED
   unbound (operator override)   -> ACCEPTED MERGED
ANG-Ventures/hermes-agent#837
   bound (default explicit path) -> REFUSED
   unbound (operator override)   -> ACCEPTED OPEN

Both were ACCEPTED on the explicit path before this change.

Tests

79 passed across all four survivor files (fork/main baseline 65; +14 new).

Mutations, each applied, run, and reverted:

mutation result
drop the binding (mined_for=None on the explicit path) 3 red — both unrelated-claim refusals + the workspace-survival consequence test
widen the mined path (corroborate default gains title/body) 1 red — test_the_wider_corroboration_is_explicit_only
kill the override (always bind) 2 red — the override acceptance + its CLI surface

Negative control held: a named OPEN/MERGED PR with no correlation to the card
HOLDS the workspace; test_unrelated_live_claim_does_not_delete_the_workspace
asserts the bytes on disk, not just the return value.

Three tests in test_kanban_survivor.py flake under parallel host load; proven
inherited by reproducing on the untouched base a3e3c169b9 (run 4 of 4 red
there with an identical signature).

Fork-only files — no upstream PR (verified 404 upstream).


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

@Kyzcreig

Copy link
Copy Markdown
Collaborator Author

FleetReview

Reviewed with 2 of 3 model families — openai unavailable.

Confidence: 3/5

Findings

  • P1 hermes_cli/kanban_survivor.py:246 — Explicit PR binding treats a title/body substring as proof of THIS card's work
  • P3 hermes_cli/kanban.py:745 — --survivor-ref help text still describes the pre-binding behaviour while --survivor-pr's was updated
  • P1 hermes_cli/kanban_survivor.py:275 — claimed_by audit identity is spoofable via $USER/$LOGNAME, so the only compensating control for --survivor-unbound is forgeable

FleetReview provenance · models: C=claude-code-opus-5, D=grok-4.6, G=grok-4.6 · cost: $6.64 · duration: 17m 00s · rounds: 1 · files examined: 6

Kyzcreig added a commit that referenced this pull request Sep 22, 2026
Round-2 response to Argus's changes-requested on #848.

Argus measured a dispatched worker reaching --survivor-unbound through the
CLI and completing a card with an unrelated live PR, in two environment
shapes, and asked for an identity gate: refuse the flag unless the caller is
not a worker, plus an interactive tty.

Measured on this host, that gate is not implementable. A worker runs as the
same uid as the operator (502) and forges every process-local signal:

  * is_dispatcher_owned_worker_context() fails OPEN with no
    HERMES_KANBAN_OWNER_PID, so `env -u HERMES_KANBAN_*` reads as an
    operator -- predicate False inherited, True stripped;
  * ancestry survives one double-fork: the daemonized grandchild reports
    ppid = 1;
  * sys.stdin.isatty() is False in the worker and True in a pty.fork child
    it spawns itself.

So the flag is bounded by CAPABILITY instead:

  _reusable() -- preserve(cleanup=True) does not re-verify, it reuses
  whatever completion recorded. An unbound claim would otherwise become
  STANDING authority to discard the workspace on every later reclamation
  without ever being re-tested. It is now refused as reclamation authority;
  a bound claim is still reused.

  _override_hint() -- #848's first refusal text named the flag that converts
  the reader's own refusal into a completion, and that text is persisted as
  held_reason and forwarded to a worker's model. The hint is now withheld
  when a dispatcher grant is present. The refusal still says why it refused.

Verified (python3.14, private basetemp):
  85 passed across all four survivor test files.
  Mutations, each applied -> run -> reverted, tree green after:
    drop _reusable                              2 failed
    _reusable returns previous unconditionally  2 failed
    hint always names the override              2 failed
    hint never names the override               3 failed
    drop the binding (mined_for=None)           6 failed

  Consequence probe, real preserve(), both branches:
    dir PRESENT  -- every arm REMOVED and RECOVERABLE (in-tree capture runs
                    first, so the escape buys the worker nothing)
    dir ABSENT   -- unbound HOLD, bound REUSE, none HOLD (anti-vacuity)

Also root-caused the "inherited flake" reported in round 1. It is not load
and not this diff: PYTEST_ADDOPTS pins a FIXED shared --basetemp, so
concurrent sibling pytest runs GC each other's tmp dirs. Reproduced on the
untouched base a3e3c16 by running that file twice concurrently against
one basetemp: 1 failed / 1 error. With a private basetemp, 8/8 green here
and 6/6 green on base.
@Kyzcreig

Copy link
Copy Markdown
Collaborator Author

FleetReview

Reviewed with 2 of 3 model families — openai unavailable.

Confidence: 3/5

Findings

  • P1 hermes_cli/kanban_survivor.py:272 — --survivor-unbound marks the survivor unbound even when the claim does name the card, permanently poisoning reclamation
  • P1 hermes_cli/kanban_survivor.py:313 — claimed_by audit field is trivially forgeable by the population it is meant to attribute
  • P2 hermes_cli/kanban_survivor.py:320 — _reusable leaves an unbound completion permanently HELD, and the re-assertion path its docstring promises does not exist
  • P2 tests/hermes_cli/test_kanban_survivor_binding.py:389 — test_the_new_refusal_never_echoes_a_credential never reaches the new refusal branch it claims to cover (and duplicates an existing test)
  • P3 tests/hermes_cli/test_kanban_survivor_binding.py:225 — Source-text substring guard in test_the_tool_surface_cannot_express_the_override can go red on a correct implementation
  • P1 hermes_cli/kanban_external_survivor.py:78 — Explicit-path corroboration on title/body re-admits the "text that merely mentions a card" trust this PR removes
  • P1 tests/hermes_cli/test_kanban_survivor_binding.py:312 — The worker-override test never covers the moment bytes are actually deleted (completion-time), and that path is not protected

FleetReview provenance · models: C=claude-code-opus-5, D=grok-4.6, G=grok-4.6 · cost: $12.28 · duration: 32m 48s · rounds: 1 · files examined: 6

@Kyzcreig
Kyzcreig force-pushed the daedalus-opus/t_de2e348e-survivor-claim-binding branch from e49c3fb to 5321511 Compare September 22, 2026 06:09
Kyzcreig added a commit that referenced this pull request Sep 22, 2026
Round-2 response to Argus's changes-requested on #848.

Argus measured a dispatched worker reaching --survivor-unbound through the
CLI and completing a card with an unrelated live PR, in two environment
shapes, and asked for an identity gate: refuse the flag unless the caller is
not a worker, plus an interactive tty.

Measured on this host, that gate is not implementable. A worker runs as the
same uid as the operator (502) and forges every process-local signal:

  * is_dispatcher_owned_worker_context() fails OPEN with no
    HERMES_KANBAN_OWNER_PID, so `env -u HERMES_KANBAN_*` reads as an
    operator -- predicate False inherited, True stripped;
  * ancestry survives one double-fork: the daemonized grandchild reports
    ppid = 1;
  * sys.stdin.isatty() is False in the worker and True in a pty.fork child
    it spawns itself.

So the flag is bounded by CAPABILITY instead:

  _reusable() -- preserve(cleanup=True) does not re-verify, it reuses
  whatever completion recorded. An unbound claim would otherwise become
  STANDING authority to discard the workspace on every later reclamation
  without ever being re-tested. It is now refused as reclamation authority;
  a bound claim is still reused.

  _override_hint() -- #848's first refusal text named the flag that converts
  the reader's own refusal into a completion, and that text is persisted as
  held_reason and forwarded to a worker's model. The hint is now withheld
  when a dispatcher grant is present. The refusal still says why it refused.

Verified (python3.14, private basetemp):
  85 passed across all four survivor test files.
  Mutations, each applied -> run -> reverted, tree green after:
    drop _reusable                              2 failed
    _reusable returns previous unconditionally  2 failed
    hint always names the override              2 failed
    hint never names the override               3 failed
    drop the binding (mined_for=None)           6 failed

  Consequence probe, real preserve(), both branches:
    dir PRESENT  -- every arm REMOVED and RECOVERABLE (in-tree capture runs
                    first, so the escape buys the worker nothing)
    dir ABSENT   -- unbound HOLD, bound REUSE, none HOLD (anti-vacuity)

Also root-caused the "inherited flake" reported in round 1. It is not load
and not this diff: PYTEST_ADDOPTS pins a FIXED shared --basetemp, so
concurrent sibling pytest runs GC each other's tmp dirs. Reproduced on the
untouched base a3e3c16 by running that file twice concurrently against
one basetemp: 1 failed / 1 error. With a private basetemp, 8/8 green here
and 6/6 green on base.
@Kyzcreig

Copy link
Copy Markdown
Collaborator Author

FleetReview

Reviewed with 2 of 3 model families — openai unavailable.

Confidence: 3/5

Findings

  • P1 hermes_cli/kanban_survivor.py:313 — _claimant() records $USER, not the OS user — the unbound-override audit trail is forgeable by the population it is meant to attribute
  • P1 hermes_cli/kanban_survivor.py:267 — Weak PR binding
  • P1 hermes_cli/kanban_survivor.py:294 — The suppressed --survivor-unbound hint is still persisted into held_reason and the workspace_held event, which kanban_show replays to workers
  • P3 hermes_cli/kanban.py:747 — --survivor-ref help text not updated for the new task-id binding
  • P2 hermes_cli/kanban_survivor.py:272 — Refusal path re-verifies the claim remotely; a transient first failure yields a message that advertises the override
  • P2 tests/hermes_cli/test_kanban_survivor_binding.py:385 — Credential-redaction test never reaches the new refusal branch it claims to pin
  • P1 tests/hermes_cli/test_kanban_survivor_binding.py:130 — Widening explicit-path corroboration to PR title/body re-opens self-certification for the population this card narrowed
  • P3 tests/hermes_cli/test_kanban_survivor_binding.py:219 — Worker-surface guard is substring-matching on source text and passes vacuously today

FleetReview provenance · models: C=claude-code-opus-5, D=grok-4.6, G=grok-4.6 · cost: $15.55 · duration: 24m 43s · rounds: 1 · files examined: 7

@Kyzcreig
Kyzcreig added this pull request to the merge queue Sep 22, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Sep 22, 2026
Kyzcreig added a commit that referenced this pull request Sep 22, 2026
…binding

Four unresolved P1s on #848 @5321511f, each fixed with a test whose
expectation is computed independently of the function under test.

P1-1 attribution was forgeable. _claimant() used getpass.getuser(),
which consults $LOGNAME/$USER/$LNAME/$USERNAME before the passwd
database -- so the population the audit trail is meant to attribute
(a dispatched worker at the same uid) could record claimed_by
"operator". Resolve the real uid via pwd.getpwuid(os.getuid()) and
record the number alongside the name. The test poisons all four vars
and computes the expected string from pwd/os.getuid directly.

P1-2/P1-4 binding was weaker than the path it replaces. A substring
in the PR title or body counted as "names the task" and was recorded
as a BOUND survivor, which _reusable turns into standing delete
authority -- an umbrella changelog or "does not address t_..."
qualifies. verify_pr/verify_ref now report corroborated_by, and
_verified_explicit records a title/body match as UNBOUND: it still
closes the card, it never buys a later delete. Pinned on the
consequence via the real preserve(cleanup=True) on the dir-absent
branch, with a branch-bound anti-vacuity arm that IS reused.

P1-3 the suppressed hint was still persisted. _override_hint() read
the WRITER's environment, but the refusal it decorates is written to
held_reason and to a workspace_held event that kanban_show replays to
a worker's model -- so an operator's refusal handed the flag to a
worker redispatched onto the same card. The hint is off the message
entirely now; it rides the exception and renders at the CLI boundary,
where the environment tested belongs to the caller actually reading.
Pinned on the literal _handle_show payload, both writer shapes.

Measured: 98 passed serially across the five survivor files
(PYTEST_ADDOPTS cleared, private --basetemp); 92 before this commit.
Kyzcreig added a commit that referenced this pull request Sep 22, 2026
Round-2 response to Argus's changes-requested on #848.

Argus measured a dispatched worker reaching --survivor-unbound through the
CLI and completing a card with an unrelated live PR, in two environment
shapes, and asked for an identity gate: refuse the flag unless the caller is
not a worker, plus an interactive tty.

Measured on this host, that gate is not implementable. A worker runs as the
same uid as the operator (502) and forges every process-local signal:

  * is_dispatcher_owned_worker_context() fails OPEN with no
    HERMES_KANBAN_OWNER_PID, so `env -u HERMES_KANBAN_*` reads as an
    operator -- predicate False inherited, True stripped;
  * ancestry survives one double-fork: the daemonized grandchild reports
    ppid = 1;
  * sys.stdin.isatty() is False in the worker and True in a pty.fork child
    it spawns itself.

So the flag is bounded by CAPABILITY instead:

  _reusable() -- preserve(cleanup=True) does not re-verify, it reuses
  whatever completion recorded. An unbound claim would otherwise become
  STANDING authority to discard the workspace on every later reclamation
  without ever being re-tested. It is now refused as reclamation authority;
  a bound claim is still reused.

  _override_hint() -- #848's first refusal text named the flag that converts
  the reader's own refusal into a completion, and that text is persisted as
  held_reason and forwarded to a worker's model. The hint is now withheld
  when a dispatcher grant is present. The refusal still says why it refused.

Verified (python3.14, private basetemp):
  85 passed across all four survivor test files.
  Mutations, each applied -> run -> reverted, tree green after:
    drop _reusable                              2 failed
    _reusable returns previous unconditionally  2 failed
    hint always names the override              2 failed
    hint never names the override               3 failed
    drop the binding (mined_for=None)           6 failed

  Consequence probe, real preserve(), both branches:
    dir PRESENT  -- every arm REMOVED and RECOVERABLE (in-tree capture runs
                    first, so the escape buys the worker nothing)
    dir ABSENT   -- unbound HOLD, bound REUSE, none HOLD (anti-vacuity)

Also root-caused the "inherited flake" reported in round 1. It is not load
and not this diff: PYTEST_ADDOPTS pins a FIXED shared --basetemp, so
concurrent sibling pytest runs GC each other's tmp dirs. Reproduced on the
untouched base a3e3c16 by running that file twice concurrently against
one basetemp: 1 failed / 1 error. With a private basetemp, 8/8 green here
and 6/6 green on base.
Kyzcreig added a commit that referenced this pull request Sep 22, 2026
…binding

Four unresolved P1s on #848 @5321511f, each fixed with a test whose
expectation is computed independently of the function under test.

P1-1 attribution was forgeable. _claimant() used getpass.getuser(),
which consults $LOGNAME/$USER/$LNAME/$USERNAME before the passwd
database -- so the population the audit trail is meant to attribute
(a dispatched worker at the same uid) could record claimed_by
"operator". Resolve the real uid via pwd.getpwuid(os.getuid()) and
record the number alongside the name. The test poisons all four vars
and computes the expected string from pwd/os.getuid directly.

P1-2/P1-4 binding was weaker than the path it replaces. A substring
in the PR title or body counted as "names the task" and was recorded
as a BOUND survivor, which _reusable turns into standing delete
authority -- an umbrella changelog or "does not address t_..."
qualifies. verify_pr/verify_ref now report corroborated_by, and
_verified_explicit records a title/body match as UNBOUND: it still
closes the card, it never buys a later delete. Pinned on the
consequence via the real preserve(cleanup=True) on the dir-absent
branch, with a branch-bound anti-vacuity arm that IS reused.

P1-3 the suppressed hint was still persisted. _override_hint() read
the WRITER's environment, but the refusal it decorates is written to
held_reason and to a workspace_held event that kanban_show replays to
a worker's model -- so an operator's refusal handed the flag to a
worker redispatched onto the same card. The hint is off the message
entirely now; it rides the exception and renders at the CLI boundary,
where the environment tested belongs to the caller actually reading.
Pinned on the literal _handle_show payload, both writer shapes.

Measured: 98 passed serially across the five survivor files
(PYTEST_ADDOPTS cleared, private --basetemp); 92 before this commit.
@Kyzcreig
Kyzcreig force-pushed the daedalus-opus/t_de2e348e-survivor-claim-binding branch from 5b1064d to 4205218 Compare September 22, 2026 08:40
@Kyzcreig

Copy link
Copy Markdown
Collaborator Author

FleetReview

Reviewed with 2 of 3 model families — openai unavailable.

Confidence: 3/5

Findings

  • P1 hermes_cli/kanban_survivor.py:378 — An unbound survivor claim is laundered into a bound one by the next completion (defeats _reusable, the only bound on --survivor-unbound)
  • P3 hermes_cli/kanban.py:747 — --survivor-ref help is stale: it no longer describes the task-naming requirement it now enforces
  • P2 hermes_cli/kanban_survivor.py:283 — --survivor-ref gains a branch-name-only binding with no widening, making the common "merged to main" claim unusable except via an override that permanently forfeits reclamation
  • P2 tests/hermes_cli/test_kanban_survivor_binding.py:506 — Credential-redaction test never reaches the new refusal branch it names
  • P3 tests/hermes_cli/test_kanban_survivor_binding.py:498 — _handle_show closes the shared board fixture connection; tid in shown can pass on an error payload
  • P2 tests/hermes_cli/test_kanban_survivor_binding.py:411 — Audit trail cannot distinguish a worker's unbound claim from an operator's, though the grant signal is already consulted elsewhere

FleetReview provenance · models: C=claude-code-opus-5, D=grok-4.6 · cost: $16.95 · duration: 16m 11s · rounds: 1 · files examined: 7

@Kyzcreig

Copy link
Copy Markdown
Collaborator Author

FleetReview

Reviewed with 2 of 3 model families — openai unavailable.

Confidence: 3/5

Findings

  • P0 tests/tools/test_kanban_tool_survivor.py:60 — Tool-surface survivor test left behind by the new task-id binding — guaranteed CI failure
  • P1 hermes_cli/kanban_survivor.py:304 — Refusal branch re-queries the remote; a transient first-call failure is misreported as "live but does not name"
  • P2 tests/hermes_cli/test_kanban_survivor_binding.py:236 — claimed_by assertion raises KeyError on hosts with no passwd entry — the exact case _claimant handles
  • P2 tests/hermes_cli/test_kanban_survivor_binding.py:498 — _handle_show closes the shared board connection handed to it by the patched _connect
  • P2 tests/hermes_cli/test_kanban_survivor_binding.py:506 — Credential-redaction test never reaches the new refusal branch it names

FleetReview provenance · models: C=claude-code-opus-5, D=grok-4.6, G=grok-4.6 · cost: $17.11 · duration: 30m 36s · rounds: 3 · files examined: 7

@Kyzcreig

Copy link
Copy Markdown
Collaborator Author

FleetReview

Reviewed with 2 of 3 model families — openai unavailable.

Confidence: 3/5

Findings

  • P2 tools/kanban_tools.py:2031 — kanban_complete tool schema still documents the pre-diff contract, leaving workers with an unrecoverable refusal
  • P1 hermes_cli/kanban_survivor.py:304 — --survivor-unbound downgrades a claim that would have bound on its own
  • P1 tests/hermes_cli/test_kanban_survivor_binding.py:196 — "mined path must stay branch-only" is false when handoff text names a SHA: mined PRs still produce a BOUND survivor
  • P2 tests/hermes_cli/test_kanban_survivor_binding.py:224 — Override-attribution test errors on the exact hosts _claimant was written to tolerate (no passwd entry, no pwd)
  • P2 tests/tools/test_kanban_tool_survivor.py:163 — Smuggle test asserts only that some error occurred, not that the binding refused
  • P3 tests/hermes_cli/test_kanban_survivor_binding.py:497 — Redispatched-worker test never asserts the refusal text is in the channel it scans

FleetReview provenance · models: C=claude-code-opus-5, D=grok-4.6, G=grok-4.6 · cost: $23.25 · duration: 25m 21s · rounds: 1 · files examined: 8

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.
Round-2 response to Argus's changes-requested on #848.

Argus measured a dispatched worker reaching --survivor-unbound through the
CLI and completing a card with an unrelated live PR, in two environment
shapes, and asked for an identity gate: refuse the flag unless the caller is
not a worker, plus an interactive tty.

Measured on this host, that gate is not implementable. A worker runs as the
same uid as the operator (502) and forges every process-local signal:

  * is_dispatcher_owned_worker_context() fails OPEN with no
    HERMES_KANBAN_OWNER_PID, so `env -u HERMES_KANBAN_*` reads as an
    operator -- predicate False inherited, True stripped;
  * ancestry survives one double-fork: the daemonized grandchild reports
    ppid = 1;
  * sys.stdin.isatty() is False in the worker and True in a pty.fork child
    it spawns itself.

So the flag is bounded by CAPABILITY instead:

  _reusable() -- preserve(cleanup=True) does not re-verify, it reuses
  whatever completion recorded. An unbound claim would otherwise become
  STANDING authority to discard the workspace on every later reclamation
  without ever being re-tested. It is now refused as reclamation authority;
  a bound claim is still reused.

  _override_hint() -- #848's first refusal text named the flag that converts
  the reader's own refusal into a completion, and that text is persisted as
  held_reason and forwarded to a worker's model. The hint is now withheld
  when a dispatcher grant is present. The refusal still says why it refused.

Verified (python3.14, private basetemp):
  85 passed across all four survivor test files.
  Mutations, each applied -> run -> reverted, tree green after:
    drop _reusable                              2 failed
    _reusable returns previous unconditionally  2 failed
    hint always names the override              2 failed
    hint never names the override               3 failed
    drop the binding (mined_for=None)           6 failed

  Consequence probe, real preserve(), both branches:
    dir PRESENT  -- every arm REMOVED and RECOVERABLE (in-tree capture runs
                    first, so the escape buys the worker nothing)
    dir ABSENT   -- unbound HOLD, bound REUSE, none HOLD (anti-vacuity)

Also root-caused the "inherited flake" reported in round 1. It is not load
and not this diff: PYTEST_ADDOPTS pins a FIXED shared --basetemp, so
concurrent sibling pytest runs GC each other's tmp dirs. Reproduced on the
untouched base a3e3c16 by running that file twice concurrently against
one basetemp: 1 failed / 1 error. With a private basetemp, 8/8 green here
and 6/6 green on base.
… card

The binding in 484c3b3 made an explicit --survivor-pr corroborate the
card that names it. Three pre-existing tests in test_kanban_survivor_stale_bases.py
drive the HAPPY path through that flag against a `remote` fixture whose
headRefName was absent, so under the new binding they refused and went red:

  test_stale_bases_with_a_verified_survivor_pr_completes
  test_cli_successful_completion_exits_zero_and_says_so
  test_partial_loss_keeps_both_the_surviving_repo_and_the_operator_ref

The fixture now returns a headRefName that is UNRELATED by default -- so the
refusal tests in that file keep their teeth without being rewritten -- and a
names_card() helper makes the claim corroborate the card for the three tests
that want the happy path. No production code changed.

Verified (python3.14, private basetemp, PYTEST_ADDOPTS cleared):
  test_kanban_survivor_stale_bases.py          7 passed
  all five survivor test files, serial        92 passed

  Mutations re-run over the four gated files (55 tests), each applied ->
  run -> reverted, tree green after:
    drop _reusable                              2 failed
    _reusable returns previous unconditionally  2 failed
    hint always names the override              2 failed
    hint never names the override               3 failed
    drop the binding (mined_for=None)           6 failed
…binding

Four unresolved P1s on #848 @5321511f, each fixed with a test whose
expectation is computed independently of the function under test.

P1-1 attribution was forgeable. _claimant() used getpass.getuser(),
which consults $LOGNAME/$USER/$LNAME/$USERNAME before the passwd
database -- so the population the audit trail is meant to attribute
(a dispatched worker at the same uid) could record claimed_by
"operator". Resolve the real uid via pwd.getpwuid(os.getuid()) and
record the number alongside the name. The test poisons all four vars
and computes the expected string from pwd/os.getuid directly.

P1-2/P1-4 binding was weaker than the path it replaces. A substring
in the PR title or body counted as "names the task" and was recorded
as a BOUND survivor, which _reusable turns into standing delete
authority -- an umbrella changelog or "does not address t_..."
qualifies. verify_pr/verify_ref now report corroborated_by, and
_verified_explicit records a title/body match as UNBOUND: it still
closes the card, it never buys a later delete. Pinned on the
consequence via the real preserve(cleanup=True) on the dir-absent
branch, with a branch-bound anti-vacuity arm that IS reused.

P1-3 the suppressed hint was still persisted. _override_hint() read
the WRITER's environment, but the refusal it decorates is written to
held_reason and to a workspace_held event that kanban_show replays to
a worker's model -- so an operator's refusal handed the flag to a
worker redispatched onto the same card. The hint is off the message
entirely now; it rides the exception and renders at the CLI boundary,
where the environment tested belongs to the caller actually reading.
Pinned on the literal _handle_show payload, both writer shapes.

Measured: 98 passed serially across the five survivor files
(PYTEST_ADDOPTS cleared, private --basetemp); 92 before this commit.
Rebase onto fork/main 3154920 collided in test_kanban_survivor_stale_bases.py
with #852 (44408ee), which added `missing`/`tips` to the `remote` fixture
and narrowed the gh stub to forward exactly {state, headRefOid, mergeCommit}.

That narrowing DECLAWS names_card(): it writes headRefName, the field the
explicit binding corroborates on, and the stub dropped it -- so the three
happy-path tests silently became refusal tests. Forward every PR field
instead of an allowlist, and name the card on the --survivor-ref arm too
(verify_ref binds on the advertised ref, so refs/heads/work is an unrelated
branch now, not an unverified SHA).

Verified the helper is still load-bearing after the merge: names_card made a
no-op -> 3 red. 104 passed serially across the five survivor files.
…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.
_verified_explicit chose its refusal TEXT by comparing two independent gh/git
round-trips, and _query collapsed every failure mode into None. A blip on the
bound call therefore made the second (weaker) call succeed, and the kernel
stated as fact that a claim it never checked "is live but does not name
<card>". That sentence is persisted to held_reason and to the workspace_held
event kanban_show replays, and the CLI then offers --survivor-unbound as the
remedy -- laundering a network blip into a permanent UNBOUND downgrade that
_reusable refuses as reclamation authority forever.

Split the two outcomes at the seam: _query raises RemoteUnavailable when the
subprocess did not answer (non-zero exit, OSError, timeout, undecodable
output); None still means the remote answered and the claim does not hold up.
_verified_explicit maps RemoteUnavailable to "could not verify ... against the
remote" with no override hint. discover() still treats no-answer as "not this
candidate" -- it states no reason, so the distinction is not load-bearing
there.

Also closes the stale --survivor-ref help (verify_ref enforces the task-id
binding since d80e20e; the help still described the old contract).

Verified:
  123 passed (survivor + external-survivor + binding + stale-bases + authority
  + tools/test_kanban_tool_survivor)
Mutation battery, each applied to a clean tree and reverted (tree md5
byte-identical after):
  MT1 _query swallows the failure again        -> 2 failed
  MT2 blip reported as irrelevance again       -> 5 failed
  MT3 discover stops tolerating no-answer      -> 1 failed
  M5  drop the binding                         -> 20 failed
  M1  drop _reusable                           -> 4 failed
  M8  title/body counts as BOUND               -> 4 failed
  M10 corroborated_by never reported           -> 7 failed
  HELP --survivor-ref help forgets the binding -> 1 failed

Refs: kanban t_de2e348e, Argus round 3.
@Kyzcreig
Kyzcreig force-pushed the daedalus-opus/t_de2e348e-survivor-claim-binding branch from 52fdc3c to ecd1d5f Compare September 22, 2026 13:34
@Kyzcreig

Copy link
Copy Markdown
Collaborator Author

FleetReview

Reviewed with 2 of 3 model families — openai unavailable.

Confidence: 4/5

Findings

  • P2 hermes_cli/kanban_survivor.py:312 — A blip on the confirmation round-trip relabels a real "does not name" verdict as "could not verify", withholding the override remedy
  • P2 tests/hermes_cli/test_kanban_survivor_binding.py:254 — No arm covers a blip on the SECOND round-trip — the call whose success produced the false verdict
  • P2 tests/hermes_cli/test_kanban_survivor_binding.py:661 — test_the_new_refusal_never_echoes_a_credential cannot reach the new refusal branch; it re-tests an already-covered path
  • P2 tests/hermes_cli/test_kanban_survivor_binding.py:391 — Test hard-depends on pwd/os.getuid(), breaking exactly the environments _claimant() was written to tolerate
  • P3 tests/tools/test_kanban_tool_survivor.py:157 — Smuggle test asserts only that some error occurred, not that the binding is what refused

FleetReview provenance · models: C=claude-code-opus-5, D=grok-4.6, G=grok-4.6 · cost: $30.85 · duration: 26m 09s · rounds: 1 · files examined: 8

fork/main landed multi-repository operator survivors (#865 family): both
claim flags became `action="append"` and each value may carry a
`<workspace-relative-repo>=` qualifier, and `_verified_explicit` changed
shape from "return one ref" to "return {repository-key-or-None: ref}".
This branch had changed the SAME function to bind each claim to the card.
Neither side is droppable, so both are kept:

  * `_verified_explicit(task_id, ...)` keeps the per-claim loop and the
    qualifier split from fork/main, and applies the binding, the
    RemoteUnavailable split and the unbound/weak-corroboration recording
    to EACH claim before it is filed under its repository key.
  * `_external` keeps fork/main's multi-ref explicit branch and this
    branch's `_reusable(previous)` gate on the cleanup path -- an unbound
    claim still never becomes standing delete authority.
  * `complete_task` takes `Sequence[str]` claims AND `survivor_unbound`.
  * The `--survivor-ref`/`--survivor-pr` help carries both contracts: the
    task-naming requirement and the repeatable `<repo>=` qualifier.

Test-side: fork/main added 17 happy-path tests driving `survivor_pr=PR`
against the `remote` fixture, whose `headRefName` is UNRELATED by design
so this file's refusal tests keep their teeth. Under the binding those
claims are correctly refused, so each now calls the existing
`names_card(remote, tid)` helper -- the same declawing r2 applied to the
three pre-existing happy paths. No production behaviour is relaxed for
them; the refusal tests are untouched. `test_the_help_documents_the_task_naming_requirement`
tracks the new `[REPO=]` metavars.

Verified (python3.14, PYTEST_ADDOPTS cleared, private --basetemp, serial):
  226 passed -- survivor + external-survivor + binding + stale-bases +
  authority + tools/test_kanban_tool_survivor + pr-gate

Refs: kanban t_de2e348e.
@Kyzcreig

Copy link
Copy Markdown
Collaborator Author

FleetReview

Reviewed with 2 of 3 model families — openai unavailable.

Confidence: 3/5

Findings

  • P1 hermes_cli/kanban_survivor.py:511 — _reusable() drops the isinstance(ref, dict) guard every other refs consumer in this module applies
  • P1 hermes_cli/kanban_survivor.py:351 — --survivor-unbound is invocation-wide: it strips the task-id binding from every repeated/qualified claim, not the one being overridden
  • P3 hermes_cli/kanban_external_survivor.py:58 — _query classifies every non-zero exit as "the remote did not answer", but gh pr view exits 1 for a PR that genuinely does not exist
  • P2 hermes_cli/kanban_external_survivor.py:39 — RemoteUnavailable sits outside preserve's fail-closed exception net
  • P2 tests/hermes_cli/test_kanban_survivor_binding.py:391 — Override-audit test hard-codes the passwd-backed claimed_by shape and errors where _claimant() documents a fallback
  • P2 tests/tools/test_kanban_tool_survivor.py:157 — Smuggle test asserts only that an error occurred, so it can pass while the binding it guards is broken
  • P3 tests/tools/test_kanban_tool_survivor.py:133 — Happy-path test never asserts the accepted claim is BOUND, which is the distinction the PR adds
  • P1 hermes_cli/kanban_survivor.py:559 — Unbound reclaim
  • P1 hermes_cli/kanban_external_survivor.py:86 — verify_ref keys matches by oid, so the new task-id binding is tested against an arbitrary ref when one SHA has several tips
  • P3 tests/hermes_cli/test_kanban_survivor_binding.py:115 — "the bytes must survive" assertion cannot fail — completion never deletes a workspace
  • P3 tests/hermes_cli/test_kanban_survivor_binding.py:254 — Parametrize arm {1, 2} is indistinguishable from {1} — "control C" measures nothing extra

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

@Kyzcreig
Kyzcreig added this pull request to the merge queue Sep 22, 2026
Merged via the queue into main with commit 8300bd7 Sep 22, 2026
54 checks passed
@Kyzcreig
Kyzcreig deleted the daedalus-opus/t_de2e348e-survivor-claim-binding branch September 22, 2026 16:10
Kyzcreig added a commit that referenced this pull request Sep 23, 2026
fork/main advanced 83 commits since 0dc715c, including the survivor
stack (#837 #842 #848 #856 #872 #879 #886 #888) which rewrote
kanban_survivor.py from 475 to 1516 lines. Four conflicts, all in
kanban_survivor.py, resolved toward main's shapes:

  _git         union: main's input= (needed by _present_commits/_rev_list)
               plus this PR's timeout= (needed by _content_advisory's
               120s fetch).
  _capture     kept main's extraction; folded this PR's canonical-tree
               fallback + mirror_hint advisory INTO it, so the
               _explain_broken_object_store classifier still wraps every
               object-reading step.
  ref arm      kept main's 'not bundles and len(refs) == len(repos) +
               len(carried)' (the #842/#848 carried-survivor accounting)
               and this PR's canonical sidecar.

The pre-#848 unbound-claim path is NOT reintroduced: _verified_explicit,
_unbound_keys, _reusable and _bound are main's, untouched. The landed
arm in preserve() and _verify_landed/_landed_contains_history merged
without conflict.

Verified:
  11 survivor files (4 from this PR + 7 landed since): 255 passed, 0 failed
  the PR's own 4 files:                                  98 passed, 0 failed
  mutation, dirty-tree guard neutered:    16 passed, 2 FAILED
  mutation, history binding neutered:     15 passed, 3 FAILED
  ruff on kanban_survivor.py + kanban_db.py: clean
  git diff --check: clean
Kyzcreig added a commit that referenced this pull request Sep 23, 2026
…requires (t_c70dac5c)

#875 was a 6-commit stack whose first five were older copies of what #842/#848/#886
landed; only c0340b1 (the complete TOOL accepting a qualified --survivor-pr claim)
was new. Re-ported that one commit linearly onto main (clean cherry-pick, 2 files,
+192/-13). Its remote_multi fixture predates #848's rule that a live PR must name the
card it vouches for, so 3 of its 5 new tests failed on main with "is live but does not
name t_..."; the stub now answers headRefName=operator/<card>-landed-elsewhere like the
single-repo fixture in the same file. tests/tools/test_kanban_tool_survivor.py 16/16.
Apollo merge pass 2026-09-23.
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