Skip to content

fix(bin): recover legacy endpoint bindings instead of refusing lifecycle actions - #2675

Closed
doitdigital0495 wants to merge 4 commits into
kunchenguid:mainfrom
doitdigital0495:fm/fm-relaunch-fix
Closed

doitdigital0495 wants to merge 4 commits into
kunchenguid:mainfrom
doitdigital0495:fm/fm-relaunch-fix

Conversation

@doitdigital0495

Copy link
Copy Markdown

Intent

Let a second mate with legacy endpoint metadata be relaunched, instead of refusing.

WHAT HAPPENED (2026-08-20). A live second mate (rentmax-mate, Herdr backend) needed restarting. Both supported lifecycle verbs refused: 'bin/fm-control.sh rentmax-mate relaunch' and 'bin/fm-control.sh rentmax-mate exit' both printed 'REFUSED: legacy Herdr endpoint metadata for task rentmax-mate lacks an exact task binding; preserving task state.' Its state/rentmax-mate.meta carried window=personal:wW:p2 plus herdr_session / herdr_workspace_id / herdr_tab_id / herdr_pane_id, but not the newer binding the control plane requires. The refusal is correct in spirit - the plane must not act on an endpoint it cannot prove is the right one - but the outcome was that firstmate had to stop the agent by hand with kill and respawn it, which is exactly the improvised lifecycle handling the control plane exists to remove.

WHAT WAS ASKED FOR. Make a legacy-metadata direct report relaunchable through the supported path, WITHOUT weakening the identity guarantee that makes the refusal correct. Establish first, from the code rather than assumption: what exact binding the plane requires today and which producer writes it; what a legacy record has instead; and whether the legacy fields identify the endpoint UNAMBIGUOUSLY or only probably. Then pick the honest fix: if the legacy fields DO identify the endpoint unambiguously, accept them - ideally upgrading the record to the current shape as it goes, so the next call takes the normal path; if they genuinely do not, then a blanket refusal is wrong in a different way because it leaves the operator with no supported action at all, so give the plane a way to re-derive or re-bind the identity, and refuse only when even that cannot be proven. Explicit constraint: do NOT weaken the check merely to make the error go away - a plane that acts on the wrong pane is far worse than one that refuses. Cover it with a test that reproduces the exact refusal against a legacy-shaped record and proves the new path both works and still refuses a genuinely unidentifiable endpoint. Also consider whether the same gap exists for the tmux backend and for ordinary crewmates, not just Herdr second mates, and report what is found either way. Definition of done includes a clear statement of what identity guarantee still holds.

WHAT THE INVESTIGATION ESTABLISHED FROM THE CODE. endpoint_task_id is written by bin/fm-spawn.sh as the self-referential line endpoint_task_id=$ID (fm-spawn.sh lines 612 and 2643), added in commit fbece9c (#1171, 2026-07-28). Its only real guarantee is detecting a record whose CONTENT was authored for a different task - a copied or renamed meta file - since fm_backend_validate_task_endpoint refuses when the binding names another task. tmux never needed it because its window field is session:fm-, so the record carries the task label itself and a foreign record cannot pass. Orca is structurally identical (window=fm-) yet still demanded the binding, which was redundant. Herdr, Zellij and cmux record opaque runtime ids that carry no task label, so a legacy record there genuinely has NOTHING offline that binds it to its task. Conclusion: the legacy fields do NOT identify the endpoint unambiguously offline, so the second branch of the brief applies - give the plane a way to re-derive the identity. The gap is purely temporal and applies to ordinary crewmates and scouts exactly as much as to second mates, because fm-spawn writes the binding unconditionally for every kind; only records written before #1171 on a non-tmux backend are affected.

WHAT WAS BUILT AND WHY. bin/fm-spawn.sh creates every task's tab or workspace with the caller-facing label fm- (W="fm-$ID" at fm-spawn.sh:1848, passed to each backend's create_task and to fm_backend_herdr_projection_create_task) and nothing ever renames it, so the binding is readable from LIVE state even when the record predates the metadata field. That is the same fact tmux and Orca get for free from their recorded window name, read from the runtime instead of from the record. Changes: (1) fm_backend_validate_task_endpoint now returns 2 rather than 1 for the one recoverable shape - a record internally consistent in every other respect whose only missing fact is the binding - and the per-backend binding check moved after the field-consistency checks so code 2 means only that; every existing caller treats any nonzero as a refusal and is unaffected. (2) New fm_backend_endpoint_label_provable proves the whole recorded chain against live state, not just the label: for Herdr, workspace -> pane -> owning tab -> label, via new fm_backend_herdr_tab_matches_label, so a re-parented pane, a relabeled tab, a duplicated label, a missing pane, or an unreachable server all refuse; Zellij reuses its existing fm_backend_zellij_tab_matches_label, which already handles the ambiguous legacy bare title; cmux gets a new fm_backend_cmux_workspace_matches_label that requires exactly one live workspace to carry the title and it to be the recorded one, deliberately unlike fm_backend_cmux_workspace_id_for_label which adopts the first match. (3) New fm_backend_bind_task_endpoint records a proven binding through a temp file in the same directory at mode 0600, and NEVER rewrites an existing binding, right or wrong, because rewriting one is how a wrong endpoint would get laundered into a right-looking record. (4) New fm_backend_resolve_task_endpoint composes those: on code 2 it proves, records, and revalidates from the upgraded record, so the next call takes the ordinary offline path; on failure the original refusal stands. (5) bin/fm-control.sh and bin/fm-teardown.sh use the resolver; the runtime read is read-only and happens before any mutation. Locking is deliberately the caller's responsibility because bin/fm-teardown.sh already holds the meta lock across its whole validate-and-clean sequence and locking inside the resolver would deadlock it; fm-control acquires and releases the meta lock around the call only, so it does not hold it across the fm-spawn --relaunch it later delegates to. (6) Orca no longer requires the binding at all, since its recorded window is already fm- and a legacy Orca record was never ambiguous.

DELIBERATE SCOPE DECISIONS a reviewer would otherwise question. bin/fm-spawn.sh's own --relaunch validation and bin/fm-remote-secondmate-control.sh were deliberately NOT wired to the resolver: fm-spawn --relaunch is reached through fm-control, which has already resolved and recorded the binding by then, and wiring it would require adding new lock churn around a validation that currently runs before fm-spawn takes the meta lock; fm-remote-secondmate-control validates a REMOTE endpoint record for which no local runtime exists to query, so it must keep refusing. The validator was deliberately kept offline-by-default rather than making it call the runtime itself, because bin/fm-teardown.sh depends on it deciding before any runtime command runs; the live read was put in a separate resolver instead.

IDENTITY GUARANTEE THAT STILL HOLDS, as required by the definition of done. Unchanged in strength. Acceptance still requires the recorded endpoint to carry the task's own fm- label. The only change is that on the opaque-id backends the label is read from the live runtime rather than from the record. That is the same test the already-accepted legacy tmux records pass, against a live source, and it has the same residual gap - two homes sharing one session AND one task id. An inconclusive read never licenses a rebind: an unreachable runtime, an unparseable response, a missing, moved or duplicated label, and a backend with no label to read all refuse.

TESTS. tests/fm-teardown-endpoint-safety.test.sh gained a case covering both directions: it reproduces the exact reported refusal against a legacy-shaped Herdr record (now code 2 with the same message), proves a legacy Orca record validates offline with no binding and no live read, proves recovery works and is durable (a second resolve makes zero runtime calls), proves five distinct unprovable live shapes - relabeled tab, re-parented pane, duplicated label, missing pane, unreachable server - each refuse with no binding written, and proves a record bound to ANOTHER task stays a terminal refusal with no live read attempted and its conflicting binding left exactly as found. tests/fm-backend-herdr-smoke.test.sh gained a real-binary case exercising the same proof against a live herdr server, because a canned fake can only confirm the shape the fake already assumes.

KNOWN LIMITATION, deliberately accepted and documented rather than worked around. That live smoke case has NOT been executed yet. It runs only inside a named non-default isolated Herdr lab via bin/fm-herdr-lab.sh, and on this machine that helper's fleet-state tripwire hard-requires exactly one RUNNING default session, while this machine's fleet runs named sessions (geris, personal) with default stopped. The helper failed closed, created no lab session, and left the fleet untouched. Starting the default session or relaxing the tripwire were both refused as out of scope and as weakening shared safety infrastructure. docs/verification/runtime-backends.md states honestly that the row has not been refreshed and names the live guard as the command that refreshes it.

DOCS. docs/configuration.md, docs/agent-control.md and docs/verification/runtime-backends.md were updated at their existing owning passages rather than by adding new sections, per the repo's one-owner and size-discipline rules in the firstmate-coding-guidelines skill. bin/fm-doc-audience-check.sh passes.

PRE-EXISTING FAILURES, verified by stashing the branch and re-running against clean HEAD, so they are NOT caused by this change and were deliberately left alone under firstmate's scope discipline: tests/fm-backend-cmux.test.sh 'send_text_submit should report send-failed when the target is absent, got unknown', and tests/fm-teardown.test.sh 'herdr-child-preflight: refusal did not explain its non-mutating boundary'.

What Changed

  • fm_backend_validate_task_endpoint now returns 2 (instead of 1) for the one recoverable shape - a Herdr/Zellij/cmux record that is consistent in every other respect and only lacks the endpoint_task_id binding - with the per-backend binding check moved after the field-consistency checks; Orca no longer requires the binding at all, since its recorded window is already fm-<id>. Existing callers treat any nonzero as a refusal and are unaffected.
  • Added a re-derivation path in bin/fm-backend.sh: fm_backend_endpoint_label_provable proves the recorded endpoint against live state (Herdr proves workspace -> pane -> owning tab -> label via the new fm_backend_herdr_tab_matches_label; cmux via the new fm_backend_cmux_workspace_matches_label, which demands exactly one live workspace carrying the title; Zellij reuses its existing tab matcher), fm_backend_bind_task_endpoint records a proven binding through a same-directory temp file at mode 0600 and never rewrites an existing binding, and fm_backend_resolve_task_endpoint composes them and revalidates offline from the upgraded record. Any inconclusive read - unreachable server, missing, moved, or duplicated label - still refuses.
  • bin/fm-control.sh and bin/fm-teardown.sh now call the resolver (fm-control takes and releases the meta lock around the call; fm-teardown already holds it), the live read stays read-only and precedes any mutation, and docs/configuration.md, docs/agent-control.md, and docs/verification/runtime-backends.md were updated at their existing owning passages, including a note that the new live Herdr smoke case has not been executed yet.

Risk Assessment

⚠️ Medium: The change loosens an authorization gate (endpoint identity) on three backends and is thoroughly tested and documented only for herdr, with the zellij and cmux proof branches carrying no executable coverage and three explicitly accepted known limitations left as follow-ups; every unproven path still refuses, so it is safe to merge with those as follow-ups.

Testing

Reproduced the reported incident verbatim through the real bin/fm-control.sh entrypoint on base code, then demonstrated the fix end-to-end on the same sandbox record: the legacy Herdr endpoint's identity is re-derived from the live fm-<id> label, recorded, and both lifecycle verbs get past the refusal, while relabeled-tab and unreachable-server shapes still refuse without writing a binding. Also verified through the CLI that the gap is kind-independent and that tmux was never affected, that fm-teardown.sh recovers on the same path, that the new unit case genuinely fails on base and passes on target, and that the related control/backend/teardown suites are green apart from two failures that reproduce identically on base. This is a shell control-plane change with no rendered user surface, so the reviewer-visible evidence is CLI transcripts and the resulting persisted task-record state rather than screenshots. One gap remains: the new live-binary Herdr smoke case cannot execute on this machine because the isolated-lab helper fails closed on its fleet-state tripwire, identically on base and target.

Evidence: Before/after CLI transcript: legacy second mate relaunch, plus the unprovable shapes and the teardown path

Source: Before/after CLI transcript: legacy second mate relaunch, plus the unprovable shapes and the teardown path

# Legacy-metadata second mate relaunch: end-to-end CLI transcript

Reproduces the 2026-08-20 incident through the real entrypoint (bin/fm-control.sh),
against an isolated sandbox FM_HOME holding a legacy-shaped Herdr record for
'rentmax-mate' (window=personal:wW:p2 + herdr_session/workspace/tab/pane, no
endpoint_task_id) and a stub herdr CLI serving the live pane/tab listing.

===============================================================================
BEFORE (base 1cb900c) - live endpoint genuinely IS this task's
===============================================================================
$ bin/fm-control.sh rentmax-mate relaunch
REFUSED: legacy Herdr endpoint metadata for task rentmax-mate lacks an exact task binding; preserving task state.
  [exit code: 1]

$ bin/fm-control.sh rentmax-mate exit
REFUSED: legacy Herdr endpoint metadata for task rentmax-mate lacks an exact task binding; preserving task state.
  [exit code: 1]

--- state/rentmax-mate.meta after the attempts ---
window=personal:wW:p2
worktree=/tmp/nm-e2e/ev-base/worktree
project=/tmp/nm-e2e/ev-base/project
backend=herdr
kind=secondmate
harness=claude
herdr_session=personal
herdr_workspace_id=wW
herdr_tab_id=wW:t2
herdr_pane_id=wW:p2

===============================================================================
AFTER (8dc03cc) - same record, same live endpoint
===============================================================================
$ bin/fm-control.sh rentmax-mate relaunch
REFUSED: legacy Herdr endpoint metadata for task rentmax-mate lacks an exact task binding; preserving task state.
note: task rentmax-mate carried a legacy endpoint record; re-derived its binding from the live endpoint's own fm-rentmax-mate label and recorded it.
error: task rentmax-mate's recorded worktree /tmp/nm-e2e/ev-fixed/worktree is not a git worktree; refusing to relaunch without a checkout whose unlanded work can be accounted for
  [exit code: 1]

$ bin/fm-control.sh rentmax-mate exit
error: task rentmax-mate's endpoint reads 'unreadable' rather than a positively classified state; refusing to send a lifecycle command into an unattributed endpoint
  [exit code: 1]

--- state/rentmax-mate.meta after the attempts ---
window=personal:wW:p2
worktree=/tmp/nm-e2e/ev-fixed/worktree
project=/tmp/nm-e2e/ev-fixed/project
backend=herdr
kind=secondmate
harness=claude
herdr_session=personal
herdr_workspace_id=wW
herdr_tab_id=wW:t2
herdr_pane_id=wW:p2
endpoint_task_id=rentmax-mate

NOTE: after the fix both verbs get PAST the identity refusal and the record is
upgraded with endpoint_task_id=rentmax-mate. The remaining errors are ordinary
downstream checks failing on sandbox scaffolding (the temp worktree is not a git
checkout; the stub herdr cannot report an agent state) - not identity refusals.

===============================================================================
AFTER (8dc03cc) - live endpoint CANNOT be proven: tab relabeled to fm-someone-else
===============================================================================
$ bin/fm-control.sh rentmax-mate relaunch
REFUSED: legacy Herdr endpoint metadata for task rentmax-mate lacks an exact task binding; preserving task state.
REFUSED: could not prove from the live endpoint that task rentmax-mate's recorded endpoint carries its own fm-rentmax-mate label; preserving task state.
  [exit code: 1]

$ bin/fm-control.sh rentmax-mate exit
REFUSED: legacy Herdr endpoint metadata for task rentmax-mate lacks an exact task binding; preserving task state.
REFUSED: could not prove from the live endpoint that task rentmax-mate's recorded endpoint carries its own fm-rentmax-mate label; preserving task state.
  [exit code: 1]

--- state/rentmax-mate.meta after the attempts ---
window=personal:wW:p2
worktree=/tmp/nm-e2e/ev-relabeled/worktree
project=/tmp/nm-e2e/ev-relabeled/project
backend=herdr
kind=secondmate
harness=claude
herdr_session=personal
herdr_workspace_id=wW
herdr_tab_id=wW:t2
herdr_pane_id=wW:p2

===============================================================================
AFTER (8dc03cc) - herdr server unreachable
===============================================================================
$ bin/fm-control.sh rentmax-mate relaunch
REFUSED: legacy Herdr endpoint metadata for task rentmax-mate lacks an exact task binding; preserving task state.
REFUSED: could not prove from the live endpoint that task rentmax-mate's recorded endpoint carries its own fm-rentmax-mate label; preserving task state.
  [exit code: 1]

$ bin/fm-control.sh rentmax-mate exit
REFUSED: legacy Herdr endpoint metadata for task rentmax-mate lacks an exact task binding; preserving task state.
REFUSED: could not prove from the live endpoint that task rentmax-mate's recorded endpoint carries its own fm-rentmax-mate label; preserving task state.
  [exit code: 1]

--- state/rentmax-mate.meta after the attempts ---
window=personal:wW:p2
worktree=/tmp/nm-e2e/ev-unreachable/worktree
project=/tmp/nm-e2e/ev-unreachable/project
backend=herdr
kind=secondmate
harness=claude
herdr_session=personal
herdr_workspace_id=wW
herdr_tab_id=wW:t2
herdr_pane_id=wW:p2

===============================================================================
The same recovery on the other lifecycle entrypoint: bin/fm-teardown.sh --force
===============================================================================
--- BASE (1cb900c) ---
REFUSED: legacy Herdr endpoint metadata for task rentmax-mate lacks an exact task binding; preserving task state.
--- FIXED (8dc03cc) ---
REFUSED: legacy Herdr endpoint metadata for task rentmax-mate lacks an exact task binding; preserving task state.
note: task rentmax-mate carried a legacy endpoint record; re-derived its binding from the live endpoint's own fm-rentmax-mate label and recorded it.
●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
●  WATCHER DOWN - SUPERVISION IS OFF
Evidence: Scope evidence: gap is kind-independent, tmux unaffected

Source: Scope evidence: gap is kind-independent, tmux unaffected

# Scope of the gap: which backends and which crew kinds

Same legacy-record scenario driven through bin/fm-control.sh, varying kind and backend.

--- legacy herdr record, kind=ship ---
REFUSED: legacy Herdr endpoint metadata for task crew-a lacks an exact task binding; preserving task state.
note: task crew-a carried a legacy endpoint record; re-derived its binding from the live endpoint's own fm-crew-a label and recorded it.
binding lines now in record: 1

--- legacy herdr record, kind=scout ---
REFUSED: legacy Herdr endpoint metadata for task crew-a lacks an exact task binding; preserving task state.
note: task crew-a carried a legacy endpoint record; re-derived its binding from the live endpoint's own fm-crew-a label and recorded it.
binding lines now in record: 1

--- legacy herdr record, kind=secondmate ---
REFUSED: legacy Herdr endpoint metadata for task crew-a lacks an exact task binding; preserving task state.
note: task crew-a carried a legacy endpoint record; re-derived its binding from the live endpoint's own fm-crew-a label and recorded it.
binding lines now in record: 1

--- tmux, on the FIXED code ---
--- legacy tmux record, kind=ship ---
(no identity refusal - endpoint identity accepted)
binding lines now in record: 0

--- the identical tmux record, on the BASE code (unchanged behavior) ---
--- legacy tmux record, kind=ship ---
(no identity refusal - endpoint identity accepted)
binding lines now in record: 0

CONCLUSION: the gap is kind-independent (ordinary crewmates and scouts recover
exactly like second mates), and tmux never had it - a legacy tmux record carries
fm-<id> in its own recorded window, is accepted offline on base and on fixed alike,
and gets no binding written.
Evidence: Core before/after: the exact reported refusal, then recovery
=== BEFORE (base 1cb900c) ===
$ bin/fm-control.sh rentmax-mate relaunch
REFUSED: legacy Herdr endpoint metadata for task rentmax-mate lacks an exact task binding; preserving task state.
[exit code: 1]
$ bin/fm-control.sh rentmax-mate exit
REFUSED: legacy Herdr endpoint metadata for task rentmax-mate lacks an exact task binding; preserving task state.
[exit code: 1]

=== AFTER (8dc03cc), same record and same live endpoint ===
$ bin/fm-control.sh rentmax-mate relaunch
REFUSED: legacy Herdr endpoint metadata for task rentmax-mate lacks an exact task binding; preserving task state.
note: task rentmax-mate carried a legacy endpoint record; re-derived its binding from the live endpoint's own fm-rentmax-mate label and recorded it.
error: task rentmax-mate's recorded worktree ... is not a git worktree; refusing to relaunch without a checkout whose unlanded work can be accounted for
(identity accepted; this last error is sandbox scaffolding, not an identity refusal)

--- state/rentmax-mate.meta after the attempts ---
window=personal:wW:p2
backend=herdr
herdr_session=personal
herdr_workspace_id=wW
herdr_tab_id=wW:t2
herdr_pane_id=wW:p2
endpoint_task_id=rentmax-mate <-- re-derived and recorded

=== AFTER (8dc03cc), live tab relabeled to fm-someone-else ===
REFUSED: could not prove from the live endpoint that task rentmax-mate's recorded endpoint carries its own fm-rentmax-mate label; preserving task state.
[exit code: 1] (no endpoint_task_id written)
- Outcome: ⚠️ 1 warning across 1 run (10m31s)

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 4 issues found → auto-fixed (2) ✅
  • ⚠️ bin/backends/cmux.sh:352 - fm_backend_cmux_workspace_matches_label reads workspace list --json with no --window, which this same file documents as CURRENT-window-scoped and verified live (fm_backend_cmux_window_of_workspace comment, bin/backends/cmux.sh:605). Two consequences: (a) the recovery this change adds does not work for a cmux task whose workspace is not in the focused window - the recorded workspace is simply absent from the list, so the proof refuses and the operator is still stranded, which is the exact gap the intent says cmux is covered for; (b) the ambiguity guard ('exactly one live workspace carries the title') cannot see a same-titled workspace in another window, so a duplicated legacy bare title is invisible and the proof can accept a workspace it should have refused as ambiguous. Suggested fix: enumerate windows via list-windows --json and per-window workspace list --window &lt;id&gt; (the pattern already used by fm_backend_cmux_window_of_workspace) before deciding both membership and uniqueness.
  • ⚠️ bin/fm-backend.sh:622 - fm_backend_bind_task_endpoint composes the new record as cat &#34;$meta&#34; &amp;&amp; printf &#39;endpoint_task_id=%s\n&#39;. If the record has no trailing newline, the binding is concatenated onto the last line (e.g. herdr_pane_id=w1:p2endpoint_task_id=&lt;id&gt;), which both loses the last field and fails to record a binding - the record is left permanently corrupt and every later validation refuses it. The repo's existing meta rewriter (bin/fm-pr-check.sh:108-114) avoids this by reading line-by-line with while IFS= read -r line || [ -n &#34;$line&#34; ] and re-emitting each line newline-terminated. Fix: use the same read-loop form, or append a newline when the file does not end with one.
  • ℹ️ bin/fm-backend.sh:565 - The comment states the proof covers 'the whole recorded chain, not just the label'. That holds only for herdr (workspace -> pane -> owning tab -> label). The zellij branch calls fm_backend_zellij_tab_matches_label, which proves only that the recorded tab id carries the label and never that the recorded zellij_pane_id still belongs to that tab; the cmux branch proves only the workspace title and never that the recorded cmux_surface_id is still in that workspace. The target subsequently acted on by fm-control/fm-teardown is the pane/surface, not the tab/workspace, so the residual gap is larger on those two backends than the comment and docs/configuration.md claim. Either bind the pane/surface to the proven container (herdr already shows the shape) or narrow the wording.
  • ℹ️ bin/fm-control.sh:297 - CONTROL_META_LOCK is acquired at line 297 and released at line 300 with no coverage from the existing control_cleanup EXIT trap, unlike CONTROL_LOCK. A signal delivered while fm_backend_resolve_task_endpoint runs (it can block on a live runtime query) leaves the meta lock dir behind. It is self-healing via fm_lock_try_acquire's stale-owner steal, so impact is low, but the established pattern (bin/fm-pr-check.sh:100 with META_LOCK_HELD plus trap) is to register it. Fix: track a held flag and release it in control_cleanup.

🔧 Fix: fix binder newline handling, meta lock trap, narrow proof claims
3 issues (1 warning, 2 infos) still open:

  • ⚠️ bin/fm-teardown.sh:2167 - preflight_firstmate_home_herdr_children (bin/fm-teardown.sh:2167) and validate_firstmate_home_children_removal (bin/fm-teardown.sh:2024) still call the bare offline fm_backend_validate_task_endpoint, so a CHILD task record inside a secondmate's home that predates fix: adapt Grok Stop continuation and harden endpoint cleanup #1171 now returns 2 and both loops || return 1. Concrete path: tearing down the very secondmate the intent names (a home whose state/ was written in the same pre-2026-07-28 era as its own legacy record) refuses on the first legacy child meta, with the same 'lacks an exact task binding' message and no supported action on that teardown. The intent lists exactly two deliberately-unwired paths (fm-spawn --relaunch and fm-remote-secondmate-control) and this is neither, so the authorized failure is still reachable one path over. The earliest shared boundary is these two call sites, but wiring the resolver there needs the CHILD's meta lock (the loop only holds the parent's), which is presumably why it was skipped. Mitigation that does exist: resolving each child individually via fm-control/fm-teardown binds it and unblocks the parent. Worth a decision rather than a silent gap.
  • ℹ️ bin/fm-teardown.sh:441 - The teardown comment and the intent both describe the re-derivation as 'a read-only runtime query ... before any mutation', but fm_backend_resolve_task_endpoint also calls fm_backend_bind_task_endpoint, which rewrites state/<id>.meta in place before the authorization check concludes. That write is safe (proof-gated, meta-lock held, whole-file temp+mv, 0600, never overwrites an existing binding) and only ever appends the one missing field, so nothing is actually laundered - but the header of this hunk dropped 'It is metadata-only' while the remaining wording still reads as if the gate never writes. Narrowing that one sentence would keep the doc honest.
  • ℹ️ bin/backends/cmux.sh:352 - tests/fm-teardown-endpoint-safety.test.sh covers the herdr recovery in both directions thoroughly (provable, durable, five unprovable shapes, foreign binding, missing trailing newline), but the newly added fm_backend_cmux_workspace_matches_label and the zellij resolve branch have no executable coverage at all. Their refusal-on-unprovable behavior - the one property the intent says must never weaken - is currently established by code reading only, including the scoped-title-then-bare-title fallback and the absent-workspace refusal that the accepted limitation depends on.

🔧 Fix: document teardown child-loop and resolver-write limitations
✅ Re-checked - no issues remain.

⚠️ **Test** - 1 warning
  • ⚠️ tests/fm-backend-herdr-smoke.test.sh:104 - The new live-binary case in tests/fm-backend-herdr-smoke.test.sh could not be executed on this machine, so the label proof has never run against a real herdr server. The suite fails closed at lab setup ('fm-herdr-lab: fleet-state tripwire requires exactly one running default session') before reaching the new case; base and target both exit 1 with the same single 'not ok - could not prepare isolated Herdr lab session', so this is a pre-existing environment limitation and not a regression. All Herdr proof evidence I gathered therefore comes from a stub herdr CLI, which can only confirm the response shape the stub itself assumes. The change documents this honestly in docs/verification/runtime-backends.md. You need to decide whether to refresh that row on a machine with a running default Herdr session before merging, or accept the documented gap.
  • bash tests/fm-teardown-endpoint-safety.test.sh on target - all cases pass, including the new 'cleanup identity: a legacy non-tmux record is recoverable through a live label proof, and refuses without one'
  • Regression proof: same test file run against base bin/ (checked out from 1cb900c into an isolated copy) - fails with not ok - a legacy Herdr record should refuse offline as recoverable (code 2), got &#39;rc=1&#39;
  • Manual E2E: bin/fm-control.sh rentmax-mate relaunch and bin/fm-control.sh rentmax-mate exit against a sandbox FM_HOME holding a legacy-shaped Herdr record (window/herdr_session/workspace/tab/pane, no endpoint_task_id) plus a stub herdr CLI, run on both base 1cb900c and target 8dc03cc
  • Manual E2E negative direction: same CLI invocations with the live tab relabeled to fm-someone-else, and with the herdr server unreachable - both refuse and write no binding
  • Manual E2E scope check: same legacy record with kind=ship, kind=scout, kind=secondmate, and a legacy backend=tmux record on both base and target
  • Manual E2E: bin/fm-teardown.sh rentmax-mate --force on a legacy Herdr record, base vs target
  • bash tests/fm-control.test.sh, bash tests/fm-control-relaunch.test.sh, bash tests/fm-backend-herdr.test.sh, bash tests/fm-backend.test.sh, bash tests/fm-backend-orca.test.sh - all pass on target
  • bash tests/fm-teardown.test.sh and bash tests/fm-backend-cmux.test.sh run against BOTH base and target to confirm their failures are pre-existing and identical
  • bash tests/fm-backend-herdr-smoke.test.sh on base and target - both fail closed at Herdr lab setup, identically
✅ **Document** - passed

✅ No issues found.

⚠️ **Lint** - 1 warning
  • ⚠️ linter found issues (exit code 127)
✅ **Push** - passed

✅ No issues found.

A task record written before endpoint_task_id existed (bin/fm-spawn.sh gained
it in kunchenguid#1171) was refused outright by fm_backend_validate_task_endpoint on every
opaque-id backend. For a live secondmate on herdr that meant both supported
lifecycle verbs refused, and the only way to restart the agent was a hand-rolled
kill and respawn - exactly the improvised lifecycle handling the control plane
exists to remove.

The refusal was right that such a record proves nothing offline: herdr, zellij,
and cmux record opaque runtime ids that carry no task label, so nothing in the
record binds it to its task. It was wrong to leave no recoverable outcome.

What those backends do have is a label. fm-spawn creates each task's tab or
workspace as fm-<id> and nothing renames it, so the binding is readable from
live state even when the record predates the metadata field - the same fact
tmux and Orca get for free from their recorded window name, read from the
runtime instead of from the record.

- fm_backend_validate_task_endpoint now returns 2, not 1, for the one
  recoverable shape: a record consistent in every other respect whose only
  missing fact is the binding. Every existing caller treats nonzero as a
  refusal and is unaffected.
- fm_backend_endpoint_label_provable proves the whole recorded chain against
  live state, not just the label. For herdr that is workspace -> pane ->
  owning tab -> label, so a re-parented pane, a relabeled tab, a duplicated
  label, a missing pane, or an unreachable server all refuse. Zellij reuses
  its existing tab matcher; cmux gets an unambiguous workspace matcher.
- fm_backend_resolve_task_endpoint records a proven binding and revalidates,
  so the next call takes the ordinary offline path. It never rewrites an
  existing binding, right or wrong.
- fm-control.sh and fm-teardown.sh use the resolver. The runtime read is
  read-only and happens before any mutation.
- Orca no longer requires the binding at all: its recorded window is already
  fm-<id>, so a legacy Orca record was never ambiguous.

Identity guarantee: unchanged in strength. Acceptance still requires the
recorded endpoint to carry the task's own fm-<id> label; the only change is
that on these backends the label is read from the live runtime rather than
from the record. That is the same test the already-accepted legacy tmux
records pass, against a live source, and it has the same residual gap - two
homes sharing one session and one task id.
@doitdigital0495

Copy link
Copy Markdown
Author

Closing: firstmate fixes stay in the fork, not upstream. A PR here cannot run its checks (pull-only rights), so this was reopened against doitdigital0495/firstmate main instead. No review action needed.

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.

2 participants