Skip to content

fix: prevent failed captain holds from reassociating origins - #6777

Open
cloud-practitioner wants to merge 5 commits into
kunchenguid:mainfrom
cloud-practitioner:fm/fm-up-hold-origin
Open

cloud-practitioner wants to merge 5 commits into
kunchenguid:mainfrom
cloud-practitioner:fm/fm-up-hold-origin

Conversation

@cloud-practitioner

Copy link
Copy Markdown
Contributor

Intent

Offer a pull request that fixes #6461 and closes it, with a regression test.

The issue: bin/fm-captain-hold.sh hold <task> --origin <new-origin> records the new origin on the task before the backend hold succeeds.
If the command is interrupted between those two writes, or the hold fails and the rollback write also fails, the task is left associated with the new origin while still carrying only the answer recorded for its previous origin.
verify_hold_durable accepts any recorded answer via body_has_resolution_record without checking that it belongs to the current origin or postdates the current hold, and verify_entry_durable compares only the stored origin with the requested origin, so complete and verify accept the previous origin's answer as if the captain had answered the new origin's call.
Expected: completion and verification refuse, because the task was never successfully held for the new origin and that call was never answered.
The issue's suggested fixes: make the verification boundary reject an incomplete reassociation instead of relying on write order (for example accept a recorded answer only if it is newer than the current hold-set stamp or carries the origin it answered, and treat a task whose stored origin has no matching live captain hold and no matching answer as not durable), or write the new origin only after the backend hold succeeds, or record a durable in-progress marker that complete and verify refuse while present.

What Changed

  • Record --origin only after the backend captain hold succeeds, is verified, and any secondmate parent notification is published; attempt to restore the previous body on backend failure.
  • Make complete and verify reject an earlier answer beneath a hold-set stamp on an open task without captain-hold annotations.
  • Document the revised lifecycle and add regression coverage for failed restoration, interrupted reassociation, origin-write failures, parent notification deduplication, and answer replay or work completion after a failed move.

Closes #6461

Risk Assessment

✅ Low: The changes are bounded to hold-origin safety, preserve parent publication before origin-write failures, and introduce no substantiated material defects.

Testing

All twelve live CLI scenarios passed, with persisted task state, refusal diagnostics, and parent notifications captured. The targeted captain-hold suite completed in bounded batches after its initial time cap and a corrected continuation-launch setup error; existing Beads migration cases skipped on this markdown-only host. The pre-fix failure reproduced successfully. No visual UI changed, so CLI transcripts provide the product evidence.

  • Live validation: ✅ go - 12 of 12 scenarios driven live against the product
Scenario Result Live Evidence
Successfully move a released call to B and verify its new hold and answer ✅ pass live Live hold-origin CLI transcript, lines 2–126.
Interrupt an active A hold moving to B; retain A and refuse B ✅ pass live Live hold-origin CLI transcript, lines 128–256.
Deny an active hold move and its rollback; retain A and refuse B ✅ pass live Live hold-origin CLI transcript, lines 258–388.
Interrupt a date-expired A hold moving to B; refuse B despite surviving captain annotations ✅ pass live Live hold-origin CLI transcript, lines 390–518.
Deny an expired hold move and its rollback; retain A and refuse B ✅ pass live Live hold-origin CLI transcript, lines 520–650.
Interrupt a released call's move, then replay A's old answer; still refuse B ✅ pass live Live hold-origin CLI transcript, lines 652–817.
Deny a released call's move and rollback, then replay A's answer; still refuse B ✅ pass live Live hold-origin CLI transcript, lines 819–986.
Interrupt a released call's move, then complete ordinary work; still refuse B ✅ pass live Live hold-origin CLI transcript, lines 988–1143.
Deny a released call's move and rollback, then complete ordinary work; still refuse B ✅ pass live Live hold-origin CLI transcript, lines 1145–1302.
Fail a new secondmate hold's origin write; deliver one parent decision and retry without duplication ✅ pass live Live hold-origin CLI transcript, lines 1304–1457.
Fail an active secondmate hold's origin write; preserve notification delivery and deduplicate retry ✅ pass live Live hold-origin CLI transcript, lines 1459–1616.
Fail a secondmate re-hold's origin write after release; deliver the new occurrence exactly once ✅ pass live Live hold-origin CLI transcript, lines 1618–1779.
Evidence: Live hold-origin CLI transcript

Source: Live hold-origin CLI transcript


=== SCENARIO: Successful reassociation and new answer verify only for the new origin ===
$ FM_HOME=<disposable-lab> fm-lab-home.sh create /tmp/fm-lab._onnvf9t
exit=0
/tmp/fm-lab._onnvf9t

$ FM_HOME=<disposable-lab> fm-tasks-axi.sh add origin-a Review origin-a --kind scout --repo sample
exit=0
ok: added origin-a (scout, repo sample) -> Queued
task:
  id: origin-a
  title: Review origin-a
  state: queued
  blocked: no
  blocked_by: none
  held: no
  hold_reason: "-"
  hold_kind: "-"
  hold_until: "-"
  kind: scout
  repo: sample
  priority: "-"
  created: 2026-10-07
  closed: "-"
  deps: none
  links: none
  body: ""
help[2]:
  - Run `tasks-axi start origin-a` to move it to in flight
  - Run `tasks-axi block origin-a --by <other>` to record a dependency

$ FM_HOME=<disposable-lab> fm-tasks-axi.sh add origin-b Review origin-b --kind scout --repo sample
exit=0
ok: added origin-b (scout, repo sample) -> Queued
task:
  id: origin-b
  title: Review origin-b
  state: queued
  blocked: no
  blocked_by: none
  held: no
  hold_reason: "-"
  hold_kind: "-"
  hold_until: "-"
  kind: scout
  repo: sample
  priority: "-"
  created: 2026-10-07
  closed: "-"
  deps: none
  links: none
  body: ""
help[2]:
  - Run `tasks-axi start origin-b` to move it to in flight
  - Run `tasks-axi block origin-b --by <other>` to record a dependency

$ FM_HOME=<disposable-lab> fm-captain-hold.sh hold call --title Decision call --reason Choose for A --origin origin-a
exit=0
call

$ FM_HOME=<disposable-lab> fm-captain-hold.sh complete origin-a call
exit=0
complete: origin-a captain-call inventory reviewed (call)

$ FM_HOME=<disposable-lab> fm-captain-hold.sh verify origin-a
exit=0
verified: origin-a captain-call inventory

$ FM_HOME=<disposable-lab> fm-captain-hold.sh answer call --release --decision-file /tmp/fm-lab._onnvf9t/answer-a.txt
exit=0
released: call

$ FM_HOME=<disposable-lab> fm-captain-hold.sh complete origin-a call
exit=0
complete: origin-a captain-call inventory reviewed (call)

$ FM_HOME=<disposable-lab> fm-captain-hold.sh verify origin-a
exit=0
verified: origin-a captain-call inventory

$ FM_HOME=<disposable-lab> fm-captain-hold.sh hold call --reason Choose for B --origin origin-b
exit=0
call

$ FM_HOME=<disposable-lab> fm-tasks-axi.sh show call --full
exit=0
task:
  id: call
  title: Decision call
  state: queued
  blocked: no
  blocked_by: none
  held: yes
  hold_reason: "Choose for B"
  hold_kind: captain
  hold_until: "-"
  kind: captain
  repo: sample
  priority: "-"
  created: 2026-10-07
  closed: "-"
  deps: none
  links: none
  body: "Captain hold set: 2026-10-07T14:02:31Z\nCaptain hold origin: origin-b\n\nResolution recorded by fm-captain-hold.\nDecision digest: b14374c303dde44466b0c5751bbc79f2122d325ad49f10373b2bac4f75c83c73\nResolution mode: released\n\nCaptain decision:\nRelease the work for A.\n\n\nOrigin: origin-a"

$ FM_HOME=<disposable-lab> fm-captain-hold.sh complete origin-b call
exit=0
complete: origin-b captain-call inventory reviewed (call)

$ FM_HOME=<disposable-lab> fm-captain-hold.sh verify origin-b
exit=0
verified: origin-b captain-call inventory

$ FM_HOME=<disposable-lab> fm-captain-hold.sh answer call --release --decision-file /tmp/fm-lab._onnvf9t/answer-b.txt
exit=0
released: call

$ FM_HOME=<disposable-lab> fm-captain-hold.sh complete origin-b call
exit=0
complete: origin-b captain-call inventory reviewed (call)

$ FM_HOME=<disposable-lab> fm-captain-hold.sh verify origin-b
exit=0
verified: origin-b captain-call inventory

SCENARIO RESULT: pass

=== SCENARIO: active: interrupt before backend hold retains A and refuses B ===
$ FM_HOME=<disposable-lab> fm-lab-home.sh create /tmp/fm-lab.zjead_ho
exit=0
/tmp/fm-lab.zjead_ho

$ FM_HOME=<disposable-lab> fm-tasks-axi.sh add origin-a Review origin-a --kind scout --repo sample
exit=0
ok: added origin-a (scout, repo sample) -> Queued
task:
  id: origin-a
  title: Review origin-a
  state: queued
  blocked: no
  blocked_by: none
  held: no
  hold_reason: "-"
  hold_kind: "-"
  hold_until: "-"
  kind: scout
  repo: sample
  priority: "-"
  created: 2026-10-07
  closed: "-"
  deps: none
  links: none
  body: ""
help[2]:
  - Run `tasks-axi start origin-a` to move it to in flight
  - Run `tasks-axi block origin-a --by <other>` to record a dependency

$ FM_HOME=<disposable-lab> fm-tasks-axi.sh add origin-b Review origin-b --kind scout --repo sample
exit=0
ok: added origin-b (scout, repo sample) -> Queued
task:
  id: origin-b
  title: Review origin-b
  state: queued
  blocked: no
  blocked_by: none
  held: no
  hold_reason: "-"
  hold_kind: "-"
  hold_until: "-"
  kind: scout
  repo: sample
  priority: "-"
  created: 2026-10-07
  closed: "-"
  deps: none
  links: none
  body: ""
help[2]:
  - Run `tasks-axi start origin-b` to move it to in flight
  - Run `tasks-axi block origin-b --by <other>` to record a dependency

$ FM_HOME=<disposable-lab> fm-captain-hold.sh hold call --title Decision call --reason Choose for A --origin origin-a
exit=0
call

$ FM_HOME=<disposable-lab> fm-captain-hold.sh answer call --release --decision-file /tmp/fm-lab.zjead_ho/answer-a.txt
exit=0
released: call

$ FM_HOME=<disposable-lab> fm-captain-hold.sh hold call --reason Choose again for A --origin origin-a
exit=0
call

$ FM_HOME=<disposable-lab> fm-captain-hold.sh complete origin-a call
exit=0
complete: origin-a captain-call inventory reviewed (call)

$ FM_HOME=<disposable-lab> fm-captain-hold.sh verify origin-a
exit=0
verified: origin-a captain-call inventory

$ FM_HOME=<disposable-lab> fm-captain-hold.sh hold call --reason Choose for B --origin origin-b
exit=-15
FAULT: TERM before real backend hold; stamped body retains previous origin

$ FM_HOME=<disposable-lab> fm-tasks-axi.sh show call --full
exit=0
task:
  id: call
  title: Decision call
  state: queued
  blocked: no
  blocked_by: none
  held: yes
  hold_reason: "Choose again for A"
  hold_kind: captain
  hold_until: "-"
  kind: captain
  repo: sample
  priority: "-"
  created: 2026-10-07
  closed: "-"
  deps: none
  links: none
  body: "Captain hold set: 2026-10-07T14:02:41Z\nCaptain hold origin: origin-a\n\nResolution recorded by fm-captain-hold.\nDecision digest: b14374c303dde44466b0c5751bbc79f2122d325ad49f10373b2bac4f75c83c73\nResolution mode: released\n\nCaptain decision:\nRelease the work for A.\n\n\nOrigin: origin-a"

$ FM_HOME=<disposable-lab> fm-captain-hold.sh complete origin-b call
exit=1
fm-captain-hold: captain-held task call was held for origin origin-a, not origin-b; hold a task for origin-b or list the right one

$ FM_HOME=<disposable-lab> fm-captain-hold.sh verify origin-b
exit=1
fm-captain-hold: captain-held task call was held for origin origin-a, not origin-b; hold a task for origin-b or list the right one

$ FM_HOME=<disposable-lab> fm-captain-hold.sh complete origin-a call
exit=0
complete: origin-a captain-call inventory reviewed (call)

$ FM_HOME=<disposable-lab> fm-captain-hold.sh verify origin-a
exit=0
verified: origin-a captain-call inventory

$ FM_HOME=<disposable-lab> fm-captain-hold.sh hold call --reason Choose for B --origin origin-b
exit=0
call

$ FM_HOME=<disposable-lab> fm-captain-hold.sh complete origin-b call
exit=0
complete: origin-b captain-call inventory reviewed (call)

$ FM_HOME=<disposable-lab> fm-captain-hold.sh verify origin-b
exit=0
verified: origin-b captain-call inventory

SCENARIO RESULT: pass

=== SCENARIO: active: refuse before backend hold retains A and refuses B ===
$ FM_HOME=<disposable-lab> fm-lab-home.sh create /tmp/fm-lab.w5tkriou
exit=0
/tmp/fm-lab.w5tkriou

$ FM_HOME=<disposable-lab> fm-tasks-axi.sh add origin-a Review origin-a --kind scout --repo sample
exit=0
ok: added origin-a (scout, repo sample) -> Queued
task:
  id: origin-a
  title: Review origin-a
  state: queued
  blocked: no
  blocked_by: none
  held: no
  hold_reason: "-"
  hold_kind: "-"
  hold_until: "-"
  kind: scout
  repo: sample
  priority: "-"
  created: 2026-10-07
  closed: "-"
  deps: none
  links: none
  body: ""
help[2]:
  - Run `tasks-axi start origin-a` to move it to in flight
  - Run `tasks-axi block origin-a --by <other>` to record a dependency

$ FM_HOME=<disposable-lab> fm-tasks-axi.sh add origin-b Review origin-b --kind scout -

... [36621 bytes truncated] ...

_reason: "-"
  hold_kind: "-"
  hold_until: "-"
  kind: scout
  repo: sample
  priority: "-"
  created: 2026-10-07
  closed: "-"
  deps: none
  links: none
  body: ""
help[2]:
  - Run `tasks-axi start origin-a` to move it to in flight
  - Run `tasks-axi block origin-a --by <other>` to record a dependency

$ FM_HOME=<disposable-lab> fm-tasks-axi.sh add origin-b Review origin-b --kind scout --repo sample
exit=0
ok: added origin-b (scout, repo sample) -> Queued
task:
  id: origin-b
  title: Review origin-b
  state: queued
  blocked: no
  blocked_by: none
  held: no
  hold_reason: "-"
  hold_kind: "-"
  hold_until: "-"
  kind: scout
  repo: sample
  priority: "-"
  created: 2026-10-07
  closed: "-"
  deps: none
  links: none
  body: ""
help[2]:
  - Run `tasks-axi start origin-b` to move it to in flight
  - Run `tasks-axi block origin-b --by <other>` to record a dependency

$ FM_HOME=<disposable-lab> fm-lab-home.sh create /tmp/fm-lab.pfe7elzq
exit=0
/tmp/fm-lab.pfe7elzq

$ FM_HOME=<disposable-lab> fm-tasks-axi.sh add origin-a Review origin-a --kind scout --repo sample
exit=0
ok: added origin-a (scout, repo sample) -> Queued
task:
  id: origin-a
  title: Review origin-a
  state: queued
  blocked: no
  blocked_by: none
  held: no
  hold_reason: "-"
  hold_kind: "-"
  hold_until: "-"
  kind: scout
  repo: sample
  priority: "-"
  created: 2026-10-07
  closed: "-"
  deps: none
  links: none
  body: ""
help[2]:
  - Run `tasks-axi start origin-a` to move it to in flight
  - Run `tasks-axi block origin-a --by <other>` to record a dependency

$ FM_HOME=<disposable-lab> fm-tasks-axi.sh add origin-b Review origin-b --kind scout --repo sample
exit=0
ok: added origin-b (scout, repo sample) -> Queued
task:
  id: origin-b
  title: Review origin-b
  state: queued
  blocked: no
  blocked_by: none
  held: no
  hold_reason: "-"
  hold_kind: "-"
  hold_until: "-"
  kind: scout
  repo: sample
  priority: "-"
  created: 2026-10-07
  closed: "-"
  deps: none
  links: none
  body: ""
help[2]:
  - Run `tasks-axi start origin-b` to move it to in flight
  - Run `tasks-axi block origin-b --by <other>` to record a dependency

$ FM_HOME=<disposable-lab> fm-captain-hold.sh hold call --title Decision call --reason Choose for A --origin origin-a
exit=0
call

$ FM_HOME=<disposable-lab> fm-captain-hold.sh hold call --title Decision call --reason Choose for B --origin origin-b
exit=1
FAULT: remove write permission only at origin publication, after successful real backend hold
fm-captain-hold: could not record the hold origin on call

$ FM_HOME=<disposable-lab> fm-tasks-axi.sh show call --full
exit=0
task:
  id: call
  title: Decision call
  state: queued
  blocked: no
  blocked_by: none
  held: yes
  hold_reason: "Choose for B"
  hold_kind: captain
  hold_until: "-"
  kind: captain
  repo: sample
  priority: "-"
  created: 2026-10-07
  closed: "-"
  deps: none
  links: none
  body: "Captain hold set: 2026-10-07T14:04:59Z\nCaptain hold origin: origin-a\n\nOrigin: origin-a"

Persisted parent channel after failed origin write:
needs-decision [key=captain-hold-call-1] [at=1791381902]: captain hold call: Choose for B

$ FM_HOME=<disposable-lab> fm-captain-hold.sh hold call --reason Choose for B --origin origin-b
exit=0
call

Persisted parent channel after retry:
needs-decision [key=captain-hold-call-1] [at=1791381902]: captain hold call: Choose for B

$ FM_HOME=<disposable-lab> fm-captain-hold.sh complete origin-b call
exit=0
complete: origin-b captain-call inventory reviewed (call)

$ FM_HOME=<disposable-lab> fm-captain-hold.sh verify origin-b
exit=0
verified: origin-b captain-call inventory

SCENARIO RESULT: pass

=== SCENARIO: Secondmate released hold: origin-write failure still delivers one parent decision and retry deduplicates ===
$ FM_HOME=<disposable-lab> fm-lab-home.sh create /tmp/fm-lab.950v4xot
exit=0
/tmp/fm-lab.950v4xot

$ FM_HOME=<disposable-lab> fm-tasks-axi.sh add origin-a Review origin-a --kind scout --repo sample
exit=0
ok: added origin-a (scout, repo sample) -> Queued
task:
  id: origin-a
  title: Review origin-a
  state: queued
  blocked: no
  blocked_by: none
  held: no
  hold_reason: "-"
  hold_kind: "-"
  hold_until: "-"
  kind: scout
  repo: sample
  priority: "-"
  created: 2026-10-07
  closed: "-"
  deps: none
  links: none
  body: ""
help[2]:
  - Run `tasks-axi start origin-a` to move it to in flight
  - Run `tasks-axi block origin-a --by <other>` to record a dependency

$ FM_HOME=<disposable-lab> fm-tasks-axi.sh add origin-b Review origin-b --kind scout --repo sample
exit=0
ok: added origin-b (scout, repo sample) -> Queued
task:
  id: origin-b
  title: Review origin-b
  state: queued
  blocked: no
  blocked_by: none
  held: no
  hold_reason: "-"
  hold_kind: "-"
  hold_until: "-"
  kind: scout
  repo: sample
  priority: "-"
  created: 2026-10-07
  closed: "-"
  deps: none
  links: none
  body: ""
help[2]:
  - Run `tasks-axi start origin-b` to move it to in flight
  - Run `tasks-axi block origin-b --by <other>` to record a dependency

$ FM_HOME=<disposable-lab> fm-lab-home.sh create /tmp/fm-lab.drt8d9l4
exit=0
/tmp/fm-lab.drt8d9l4

$ FM_HOME=<disposable-lab> fm-tasks-axi.sh add origin-a Review origin-a --kind scout --repo sample
exit=0
ok: added origin-a (scout, repo sample) -> Queued
task:
  id: origin-a
  title: Review origin-a
  state: queued
  blocked: no
  blocked_by: none
  held: no
  hold_reason: "-"
  hold_kind: "-"
  hold_until: "-"
  kind: scout
  repo: sample
  priority: "-"
  created: 2026-10-07
  closed: "-"
  deps: none
  links: none
  body: ""
help[2]:
  - Run `tasks-axi start origin-a` to move it to in flight
  - Run `tasks-axi block origin-a --by <other>` to record a dependency

$ FM_HOME=<disposable-lab> fm-tasks-axi.sh add origin-b Review origin-b --kind scout --repo sample
exit=0
ok: added origin-b (scout, repo sample) -> Queued
task:
  id: origin-b
  title: Review origin-b
  state: queued
  blocked: no
  blocked_by: none
  held: no
  hold_reason: "-"
  hold_kind: "-"
  hold_until: "-"
  kind: scout
  repo: sample
  priority: "-"
  created: 2026-10-07
  closed: "-"
  deps: none
  links: none
  body: ""
help[2]:
  - Run `tasks-axi start origin-b` to move it to in flight
  - Run `tasks-axi block origin-b --by <other>` to record a dependency

$ FM_HOME=<disposable-lab> fm-captain-hold.sh hold call --title Decision call --reason Choose for A --origin origin-a
exit=0
call

$ FM_HOME=<disposable-lab> fm-captain-hold.sh answer call --release --decision-file /tmp/fm-lab.950v4xot/answer-a.txt
exit=0
released: call

$ FM_HOME=<disposable-lab> fm-captain-hold.sh hold call --title Decision call --reason Choose for B --origin origin-b
exit=1
FAULT: remove write permission only at origin publication, after successful real backend hold
fm-captain-hold: could not record the hold origin on call

$ FM_HOME=<disposable-lab> fm-tasks-axi.sh show call --full
exit=0
task:
  id: call
  title: Decision call
  state: queued
  blocked: no
  blocked_by: none
  held: yes
  hold_reason: "Choose for B"
  hold_kind: captain
  hold_until: "-"
  kind: captain
  repo: sample
  priority: "-"
  created: 2026-10-07
  closed: "-"
  deps: none
  links: none
  body: "Captain hold set: 2026-10-07T14:05:10Z\n\nResolution recorded by fm-captain-hold.\nDecision digest: b14374c303dde44466b0c5751bbc79f2122d325ad49f10373b2bac4f75c83c73\nResolution mode: released\n\nCaptain decision:\nRelease the work for A.\n\nCaptain hold origin: origin-a\n\nOrigin: origin-a"

Persisted parent channel after failed origin write:
needs-decision [key=captain-hold-call-2] [at=1791381912]: captain hold call: Choose for B

$ FM_HOME=<disposable-lab> fm-captain-hold.sh hold call --reason Choose for B --origin origin-b
exit=0
call

Persisted parent channel after retry:
needs-decision [key=captain-hold-call-2] [at=1791381912]: captain hold call: Choose for B

$ FM_HOME=<disposable-lab> fm-captain-hold.sh complete origin-b call
exit=0
complete: origin-b captain-call inventory reviewed (call)

$ FM_HOME=<disposable-lab> fm-captain-hold.sh verify origin-b
exit=0
verified: origin-b captain-call inventory

SCENARIO RESULT: pass
All disposable homes removed; no default session, agent login, or fleet data used.
Evidence: Pre-fix false-acceptance reproduction

Source: Pre-fix false-acceptance reproduction

$ FM_HOME=<disposable-lab> fm-lab-home.sh create /tmp/fm-lab.fcmkm4g8
exit=0
/tmp/fm-lab.fcmkm4g8

$ FM_HOME=<disposable-lab> fm-tasks-axi.sh add origin-a Review origin-a --kind scout --repo sample
exit=0
ok: added origin-a (scout, repo sample) -> Queued
task:
  id: origin-a
  title: Review origin-a
  state: queued
  blocked: no
  blocked_by: none
  held: no
  hold_reason: "-"
  hold_kind: "-"
  hold_until: "-"
  kind: scout
  repo: sample
  priority: "-"
  created: 2026-10-07
  closed: "-"
  deps: none
  links: none
  body: ""
help[2]:
  - Run `tasks-axi start origin-a` to move it to in flight
  - Run `tasks-axi block origin-a --by <other>` to record a dependency

$ FM_HOME=<disposable-lab> fm-tasks-axi.sh add origin-b Review origin-b --kind scout --repo sample
exit=0
ok: added origin-b (scout, repo sample) -> Queued
task:
  id: origin-b
  title: Review origin-b
  state: queued
  blocked: no
  blocked_by: none
  held: no
  hold_reason: "-"
  hold_kind: "-"
  hold_until: "-"
  kind: scout
  repo: sample
  priority: "-"
  created: 2026-10-07
  closed: "-"
  deps: none
  links: none
  body: ""
help[2]:
  - Run `tasks-axi start origin-b` to move it to in flight
  - Run `tasks-axi block origin-b --by <other>` to record a dependency

$ FM_HOME=<disposable-lab> before-fix-captain-hold.sh hold call --title Decision call --reason Choose for A --origin origin-a
exit=0
call

$ FM_HOME=<disposable-lab> before-fix-captain-hold.sh answer call --release --decision-file /tmp/fm-lab.fcmkm4g8/answer.txt
exit=0
released: call

$ FM_HOME=<disposable-lab> before-fix-captain-hold.sh hold call --reason Choose for B --origin origin-b
exit=1
FAULT: remove write permission from disposable backlog and data dir before backend hold
fm-captain-hold: could not record the hold origin on call

$ FM_HOME=<disposable-lab> fm-tasks-axi.sh show call --full
exit=0
task:
  id: call
  title: Decision call
  state: queued
  blocked: no
  blocked_by: none
  held: no
  hold_reason: "-"
  hold_kind: "-"
  hold_until: "-"
  kind: captain
  repo: firstmate
  priority: "-"
  created: 2026-10-07
  closed: "-"
  deps: none
  links: none
  body: "Captain hold set: 2026-10-07T14:10:23Z\nCaptain hold origin: origin-b\n\nResolution recorded by fm-captain-hold.\nDecision digest: b14374c303dde44466b0c5751bbc79f2122d325ad49f10373b2bac4f75c83c73\nResolution mode: released\n\nCaptain decision:\nRelease the work for A.\n\n\nOrigin: origin-a"

$ FM_HOME=<disposable-lab> before-fix-captain-hold.sh complete origin-b call
exit=0
complete: origin-b captain-call inventory reviewed (call)

$ FM_HOME=<disposable-lab> before-fix-captain-hold.sh verify origin-b
exit=0
verified: origin-b captain-call inventory

REPRODUCED: base incorrectly certifies B using A's answer after the real backend hold and rollback both fail.
Disposable baseline home removed.
Evidence: Validation summary and evidence index

Source: Validation summary and evidence index

# Hold-origin live validation

## Outcome

All twelve current-product CLI scenarios passed. No source or test files were changed during validation, and all disposable homes were removed.

The product was `bin/fm-captain-hold.sh` with the real installed `tasks-axi` markdown backend, invoked through `bin/fm-tasks-axi.sh`. No CLI, backend, login, or product output was mocked. `fault-env.sh` injects faults in the running Bash process by sending TERM before the backend hold or revoking write permission on the disposable backlog and its directory. The secondmate checks use the real local parent-channel publisher. No default runtime session or fleet data was used.

## Product evidence

[Current CLI transcript](live-hold-origin.log) includes commands, return codes, persisted task bodies, refusal diagnostics, and parent-channel events:

| Scenario | Transcript lines |
| --- | --- |
| Successful reassociation and new answer accepted for B | 2–126 |
| Active hold interrupted; A retained and B refused | 128–256 |
| Active hold/backend and rollback refused; A retained and B refused | 258–388 |
| Expired hold interrupted; A retained and B refused | 390–518 |
| Expired hold/backend and rollback refused; A retained and B refused | 520–650 |
| Released hold interrupted, earlier answer replayed; B still refused | 652–817 |
| Released hold/backend and rollback refused, earlier answer replayed; B still refused | 819–986 |
| Released hold interrupted, ordinary work completed; B still refused | 988–1143 |
| Released hold/backend and rollback refused, ordinary work completed; B still refused | 1145–1302 |
| Secondmate new hold: origin write refused, decision delivered once, retry succeeds | 1304–1457 |
| Secondmate active hold: origin write refused, decision delivered once, retry succeeds | 1459–1616 |
| Secondmate re-hold after release: origin write refused, decision delivered once, retry succeeds | 1618–1779 |

[Before-fix reproduction](before-fix-reproduction.log) executes the script from base commit `47aff866dbe0612bd43df66d8fa76576e06a2b3e`, changing only its library-directory binding to this worktree. With the real backend hold and rollback denied by filesystem permissions, its persisted task is unheld and associated with B but carries only A's answer. Both `complete origin-b call` and `verify origin-b` incorrectly exit zero. The current-product transcript demonstrates refusal for the corresponding failure and interruption cases.

## Targeted automated checks

The captain-hold lifecycle suite's initial ten-minute invocation completed the touched origin-ordering/parent-publication test, the expanded interrupted-origin regression, and the suite prefix, then hit the imposed time limit. Only its remaining cases were resumed; they exited zero. The first continuation launcher exceeded the OS argument-size limit; feeding its Bash program on stdin fixed that setup issue. Beads migration cases reported their existing markdown-only-host skips. No broad test tree, lint, formatting, static analysis, push, PR, or CI commands ran.

Evidence: [suite prefix](captain-hold-suite.log), [remaining cases](captain-hold-suite-remaining-retry.log), [scenario results](live-hold-origin-results.json).

This is a CLI/storage change, not a visual UI change; product transcripts and persisted state are the reviewer-visible artifacts.
- Outcome: 🔧 2 issues found → no changes applied ✅ across 2 runs (52m55s)

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 1 issue found → auto-fixed (2) ✅
  • 🚨 bin/fm-captain-hold.sh:542 - The durable fix still permits an incomplete origin move to pass completion and verification. Starting from the regression's state—A's released answer, B's stored origin and leading stamp, and a failed backend hold/rollback—retrying answer --release with A's original decision reaches bin/fm-captain-hold.sh:1244 and removes the only evidence the new guard checks. complete B and verify B then accept A's answer without B ever being held or answered. Two sibling paths violate the same invariant: moving an already-active or date-expired A hold to B and interrupting before the backend operation bypasses verification at bin/fm-captain-hold.sh:538 because A's captain annotations remain; ordinarily completing released work after the failed move bypasses the stamp check at bin/fm-captain-hold.sh:542 because the task is now done. The shared cause remains bin/fm-captain-hold.sh:1017 publishing B before backend success, with bin/fm-captain-hold.sh:1031 allowing failed restoration; the origin comparison at bin/fm-captain-hold.sh:912 cannot distinguish these states. Both consumers, bin/fm-captain-hold.sh:1796 and bin/fm-captain-hold.sh:1863, inherit the false acceptance. Commit the origin only after backend hold success, as explicitly permitted by the intent, rather than treating removable body ordering as proof of provenance. Cover active/expired moves, old-answer replay, and normal work completion in the behavioral regression, and correct the guarantees at docs/captain-hold-lifecycle.md:69 and docs/captain-hold-lifecycle.md:71.

🔧 Fix applied.
2 issues (1 error, 1 warning) still open:

  • 🚨 bin/fm-captain-hold.sh:542 - The durable fix still permits an incomplete origin move to pass completion and verification. Starting from the regression's state—A's released answer, B's stored origin and leading stamp, and a failed backend hold/rollback—retrying answer --release with A's original decision reaches bin/fm-captain-hold.sh:1244 and removes the only evidence the new guard checks. complete B and verify B then accept A's answer without B ever being held or answered. Two sibling paths violate the same invariant: moving an already-active or date-expired A hold to B and interrupting before the backend operation bypasses verification at bin/fm-captain-hold.sh:538 because A's captain annotations remain; ordinarily completing released work after the failed move bypasses the stamp check at bin/fm-captain-hold.sh:542 because the task is now done. The shared cause remains bin/fm-captain-hold.sh:1017 publishing B before backend success, with bin/fm-captain-hold.sh:1031 allowing failed restoration; the origin comparison at bin/fm-captain-hold.sh:912 cannot distinguish these states. Both consumers, bin/fm-captain-hold.sh:1796 and bin/fm-captain-hold.sh:1863, inherit the false acceptance. Commit the origin only after backend hold success, as explicitly permitted by the intent, rather than treating removable body ordering as proof of provenance. Cover active/expired moves, old-answer replay, and normal work completion in the behavioral regression, and correct the guarantees at docs/captain-hold-lifecycle.md:69 and docs/captain-hold-lifecycle.md:71.
  • ⚠️ bin/fm-captain-hold.sh:1033 - Round 1's fixer commit (bbd2fcf) introduced a post-hold exit that skips parent-channel delivery. In a secondmate home, let the backend hold succeed but refuse the subsequent origin-body update: the task remains captain-held, yet this exit bypasses publish_parent_hold at bin/fm-captain-hold.sh:1036, so no needs-decision event reaches the parent. This violates the existing script-owned delivery contract in docs/secondmate-parent-channel.md:21–28. New holds and re-holds share this path; origin staging failures at bin/fm-captain-hold.sh:846 and bin/fm-captain-hold.sh:849 and the update failure at bin/fm-captain-hold.sh:854 likewise bypass publication. Publish the successfully verified hold before attempting origin publication, while retaining the nonzero result when that publication fails. Exercise the existing origin-write failure case in a secondmate fixture and assert the parent event.

🔧 Fix applied.
✅ Re-checked - no issues remain.

🔧 **Test** - 2 issues found → no changes applied ✅
  • ⚠️ The Test agent did not finish within its invocation budget. Reported: agent run tests timed out after 30m0s: agent last produced output 21s ago (118 observed); agent reported: pi exited: exit status 143. This is a budget or provider-slowness cut, not a code failure. Re-running the same request costs another full budget, so no further attempt is made automatically. If this repository's targeted tests or evidence gathering routinely approach the default 30m0s, raise test_agent_timeout in global config. Respond with fix to spend another budget: a repair turn runs only for selected findings other than this budget cut, then validation re-runs. Or abort and retry after raising the budget.
  • 🚨 Approval is refused: the run worktree at ~/.no-mistakes/worktrees/5e28e613310e/01M4B5GBJ9SPZSFZEVT47MEJ31 holds work no Test turn validated, and the steps after Test would commit and publish it. It holds uncommitted changes to .local-test-tmp/ (inspect with git -C ~/.no-mistakes/worktrees/5e28e613310e/01M4B5GBJ9SPZSFZEVT47MEJ31 status and git -C ~/.no-mistakes/worktrees/5e28e613310e/01M4B5GBJ9SPZSFZEVT47MEJ31 diff). Respond with fix to validate it, or abort.

🔧 No changes applied.
✅ Re-checked - no issues remain.

  • Live validation: ✅ go - 12 of 12 scenarios driven live against the product
Scenario Result Live Evidence
Successfully move a released call to B and verify its new hold and answer ✅ pass live Live hold-origin CLI transcript, lines 2–126.
Interrupt an active A hold moving to B; retain A and refuse B ✅ pass live Live hold-origin CLI transcript, lines 128–256.
Deny an active hold move and its rollback; retain A and refuse B ✅ pass live Live hold-origin CLI transcript, lines 258–388.
Interrupt a date-expired A hold moving to B; refuse B despite surviving captain annotations ✅ pass live Live hold-origin CLI transcript, lines 390–518.
Deny an expired hold move and its rollback; retain A and refuse B ✅ pass live Live hold-origin CLI transcript, lines 520–650.
Interrupt a released call's move, then replay A's old answer; still refuse B ✅ pass live Live hold-origin CLI transcript, lines 652–817.
Deny a released call's move and rollback, then replay A's answer; still refuse B ✅ pass live Live hold-origin CLI transcript, lines 819–986.
Interrupt a released call's move, then complete ordinary work; still refuse B ✅ pass live Live hold-origin CLI transcript, lines 988–1143.
Deny a released call's move and rollback, then complete ordinary work; still refuse B ✅ pass live Live hold-origin CLI transcript, lines 1145–1302.
Fail a new secondmate hold's origin write; deliver one parent decision and retry without duplication ✅ pass live Live hold-origin CLI transcript, lines 1304–1457.
Fail an active secondmate hold's origin write; preserve notification delivery and deduplicate retry ✅ pass live Live hold-origin CLI transcript, lines 1459–1616.
Fail a secondmate re-hold's origin write after release; deliver the new occurrence exactly once ✅ pass live Live hold-origin CLI transcript, lines 1618–1779.
  • rm -rf -- .local-test-tmp removed the explicitly identified untracked scratch.
  • timeout 600 env TMPDIR=/tmp bash tests/fm-captain-hold-lifecycle.test.sh completed the origin-ordering test, expanded interrupted-origin regression, and suite prefix before reaching the time cap.
  • timeout 600 python3 ~/.no-mistakes/evidence/01M4B5GBJ9SPZSFZEVT47MEJ31/run-captain-hold-remaining.py completed the remaining captain-hold cases with exit 0 after fixing the continuation launcher's argument-size error.
  • python3 ~/.no-mistakes/evidence/01M4B5GBJ9SPZSFZEVT47MEJ31/live-hold-origin.py drove twelve real CLI scenarios using tasks-axi, process termination, filesystem permission failures, and local parent-channel delivery.
  • python3 ~/.no-mistakes/evidence/01M4B5GBJ9SPZSFZEVT47MEJ31/reproduce-before-fix.py reproduced incorrect completion and verification on base commit 47aff866dbe0612bd43df66d8fa76576e06a2b3e.
  • git status --short confirmed no remaining worktree changes; disposable validation homes were removed.
✅ **Document** - passed

✅ No issues found.

🔧 **Lint** - 1 issue found → auto-fixed ✅
  • ⚠️ linter found issues (exit code 1)

🔧 Fix applied.
✅ Re-checked - no issues remain.

✅ **Push** - passed

✅ No issues found.

…ete hold move

A hold moved to a new origin wrote its stamp and origin before the backend
hold. A process stopped between those writes, or a refused hold whose rollback
also failed, left the task associated with the new origin while it carried
only the earlier call's answer, and complete and verify accepted that answer.

Verification now treats an open, unheld task that leads with a hold-set stamp
above a recorded answer as a re-hold that never completed, so the answer no
longer satisfies the new origin however the move was interrupted. A refused
hold also restores the body the attempt started from, and the origin lookup
runs before any write, so a clean failure leaves the earlier answer valid.

Closes kunchenguid#6461
@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate: thanks, @cloud-practitioner, for filing #6461 and coming back with the fix. I read the whole diff of head c87e1644 against main 47aff866.

What I checked:

  • bin/fm-captain-hold.sh command_hold now resolves the --origin identity before stamping. It records Captain hold origin: only after the backend tasks-axi hold succeeds, the captain annotations and stamp are verified, and any secondmate parent notification is published. If the backend hold is refused, it tries to restore the original body instead of rewriting the previous origin line. So an interrupted or refused move can no longer publish the new association.
  • verify_hold_durable now checks a live captain hold first. An open task with no captain annotations but a leading hold-set stamp above an earlier answer is refused ("newer hold never completed"). Done tasks keep their recorded answer as evidence. That closes the complete/verify path from fm-captain-hold: an interrupted hold --origin move lets complete accept the previous origin's answer #6461 where an old origin's answer satisfied a new origin.
  • write_hold_origin now returns non-zero instead of exiting, and it no longer handles the "empty origin" case, which only the old rollback used.
  • The test (test_hold_origins_follow_backend_holds, plus the new interrupted-move test) uses fake tasks-axi wrappers in temp homes. The docs (docs/captain-hold-lifecycle.md) change with the code. There are no workflow edits, network calls or secrets.

The no-mistakes attestation matches this head, and "PR must be raised via no-mistakes" is green. The main CI run is still in progress, and the merge state is UNSTABLE while it runs.

Contract-class: restore. The specified default path is the captain-hold completion gate: complete and verify must refuse a call the captain never answered. #6461 shows it was broken by write ordering on the existing hold --origin path. This tip makes the unconfigured run refuse that case again and adds no new surface, wake or setting. The only new refusal is a task that sits stamped mid-hold, and the docs name how to recover: re-run hold, or re-run the same answer --release.

VISION.md, rule by rule

  • One captain, one interface: aligns. A captain decision can no longer be silently treated as answered for a call that was never asked.
  • Authority is explicit and never inferred: aligns. It stops one origin's answer from being read as the captain's word on another.
  • Scripts own the mechanics, agents own the judgment: aligns. This is exact write ordering plus a deterministic durability check.
  • A restart is a non-event: aligns. An interrupted move now leaves a refusable, re-runnable state instead of a false completion.
  • Delegation with a spine: aligns. It strengthens a refusal path before an investigation is cleaned up.
  • The fleet outlives any vendor: aligns. Nothing vendor-specific.
  • Scope: aligns. Command-layer hold bookkeeping only.

Next step: this is waiting on CI. If CI goes fully green on c87e1644 with the attestation still matching, it's an auto-merge candidate on a later pass. Nothing is needed from you unless CI turns red.

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.

fm-captain-hold: an interrupted hold --origin move lets complete accept the previous origin's answer

2 participants