Skip to content

feat: add recoverable primary session handoff - #1239

Open
tmad4000 wants to merge 8 commits into
kunchenguid:mainfrom
tmad4000:fm/fm-session-kick-handoff
Open

feat: add recoverable primary session handoff#1239
tmad4000 wants to merge 8 commits into
kunchenguid:mainfrom
tmad4000:fm/fm-session-kick-handoff

Conversation

@tmad4000

Copy link
Copy Markdown

Intent

Implement a first-class, recoverable primary-session takeover primitive for Firstmate using the 2026-07-29 live Paseo/Claude credit-exhaustion incident as the reproduction. Provide an agent-ergonomic scan/takeover/restore command family that persists or resolves the exact visible provider identity, proves it is the live lock owner, deterministically refuses busy, captain-action, unresolved-input, child-agent, wedged, unknown, or unsupported sessions, soft-archives only a safely recoverable idle or rate-limited provider, verifies the recorded process tree is gone, and then uses the canonical stale-lock acquisition and ordinary session-start path without deleting or weakening a live lock or permitting dual orchestration. Preserve provider transcripts and all fleet/task state; write privacy-safe durable receipts; make restoration explicit, refuse it under a live successor, and require normal lock reacquisition before restored mutation. Include a fleet-wide read-only captain-action scan, clearly classify supported primary harness/runtime combinations, place mechanics in script help with conditional policy in an agent-only skill and human/verification material in docs, and add deterministic safety and recovery tests using only fake/disposable providers. Do not manipulate any real current provider or agent while developing or validating.

What Changed

  • Adds a recoverable primary-session handoff command family for scanning, takeover, and restore, with durable privacy-safe receipts and fail-closed handling for unsafe or unsupported provider states.
  • Hardens session lock ownership, stale-lock reacquisition, watcher restart/runtime gates, and quota/takeover revalidation so handoff cannot weaken a live primary lock or create dual orchestration.
  • Documents the captain-facing handoff workflow and verification evidence, and adds deterministic fake-provider tests for primary-session safety, recovery, and adjacent session-start/watcher behavior.

Risk Assessment

✅ Low: Captain, the change is large but tightly scoped around a guarded primary-session handoff path with fail-closed transaction checks and focused fake-provider regressions; I did not find a material merge blocker in the changed code.

Testing

Captain, I exercised the documented fake-Paseo primary-session handoff suite plus the adjacent lock, session-start, native-nudge, and watcher-lock regressions; all passed, evidence logs were saved under the requested directory, and the ignored transient state/ directory created during validation was cleaned up.

Evidence: Dedicated primary-session handoff behavior transcript
FM_TEST_BEGIN 2026-07-29T10:48:59Z tests/fm-primary-session.test.sh family=session-bootstrap expected_gate_skip=none
ok - primary-session: requested external session and process identity must exactly match the live lock owner
ok - primary-session: busy, pending-action, and unknown owners fail closed with distinct states
ok - primary-session: wedged and attached-child sessions remain suspended from takeover eligibility
ok - primary-session: suspend failure and surviving owner process tree block stale-lock acquisition until resolved
ok - primary-session: post-archive receipt write failure preserves an explicit restore path
ok - fm-lock: descriptor publication failure rolls back only its own numeric claim
ok - primary-session: safe idle takeover uses stale-lock acquisition and preserves fleet state
ok - primary-session: fresh exhausted quota evidence produces a distinct recoverable pause
ok - primary-session: malformed quota evidence fails closed before archive
ok - primary-session: takeover revalidates safety after receipt publication
ok - primary-session: concurrent takeover attempts admit one suspension and one successor
ok - primary-session: restore fails closed while another live primary owns the lock
ok - primary-session: archived provider reload remains recoverable and reacquires through normal session start
ok - primary-session: fleet-wide captain-action scan is read-only and omits prompt/title prose
ok - primary-session: workspace-scoped Paseo state resolves by unique external session id
FM_TEST_END 2026-07-29T10:49:46Z tests/fm-primary-session.test.sh exit=0 duration_ms=47614 gate_skip=false
FM_TEST_SUMMARY total=1 failed=0 skipped_gate=0 duration_ms=47681
FM_TEST_SUMMARY_FAMILY family=session-bootstrap count=1 duration_ms=47614 failed=0
FM_TEST_SLOWEST rank=1 script=tests/fm-primary-session.test.sh duration_ms=47614
fm-test-run: wrote timing artifact: /var/folders/04/j5yqtsfs3l527xrkvtxzy60h0000gn/T/no-mistakes-evidence/01KYPQCHDQJ4HFRF0BS419ASV3/fm-primary-session.test.json
Evidence: Dedicated primary-session handoff timing artifact
{
  "families": [
    {
      "count": 1,
      "duration_ms": 47614,
      "failed": 0,
      "name": "session-bootstrap"
    }
  ],
  "finished_at": "2026-07-29T10:49:46Z",
  "run_id": "fm-test-run-1785322139210-8627",
  "scripts": [
    {
      "duration_ms": 47614,
      "exit": 0,
      "expected_gate_skip": "none",
      "family": "session-bootstrap",
      "gate_skip": false,
      "path": "tests/fm-primary-session.test.sh"
    }
  ],
  "selection": "scripts",
  "started_at": "2026-07-29T10:48:59Z",
  "summary": {
    "duration_ms": 47681,
    "failed": 0,
    "skipped_gate": 0,
    "total": 1
  }
}
Evidence: Adjacent lock/startup/nudge/watch behavior transcript
FM_TEST_BEGIN 2026-07-29T10:49:53Z tests/fm-claude-stop-autoarm.test.sh family=watcher-wake-lock expected_gate_skip=none
ok - settings.json registers the asyncRewake auto-arm with timeout >= 28800 and a foreground arm
ok - auto-arm: inert in a linked child worktree even when in-flight
ok - auto-arm: inert with no session lock
ok - auto-arm: a demonstrably dead recorded session owner is reclaimed through fm-lock.sh before arming
ok - auto-arm: inert without arm, rewake, or lock replacement when another live harness owns the home
ok - auto-arm: inert while AFK owns supervision
ok - auto-arm: stale-owner recovery leaves the AFK and supervision-need gates unchanged
ok - auto-arm: resolves the outermost pid of a nested contiguous claude ancestry (bg-spare chain)
ok - auto-arm: inert with nothing in flight and no X-mode need
ok - auto-arm: actionable close translates to exactly one exit-2 rewake with reason
ok - auto-arm: watcher: FAILED translates to an exit-2 alarm rewake
ok - auto-arm: clean close exits silently with a clean epoch
ok - auto-arm: X-mode poll need arms the cycle even with no tasks in flight
ok - auto-arm: concurrent firings admit one owner and one rewake translation
ok - auto-arm: need vanishing mid-cycle closes without a rewake
ok - auto-arm: mid-cycle AFK hands triage to the daemon with no rewake
ok - auto-arm: active in a marked secondmate home
ok - fm-lock: shared session-lock lib preserves the status path
FM_TEST_END 2026-07-29T10:50:55Z tests/fm-claude-stop-autoarm.test.sh exit=0 duration_ms=61905 gate_skip=false
FM_TEST_BEGIN 2026-07-29T10:50:55Z tests/fm-session-start.test.sh family=session-bootstrap expected_gate_skip=none
ok - context digest distinguishes ABSENT, empty-but-present, and populated files
ok - a lock refusal prints a loud read-only banner, skips every mutating step, and still completes the digest
ok - session start stays read-only when lock ownership cannot be published
ok - concurrent session-lock acquisition admits exactly one live harness
ok - digest sections are ordered diagnostics-first, bulk-context-last
ok - session start: configured and auto-detected Herdr homes never require tmux
ok - session start: an absent recorded tmux window relaunches its Pi secondmate exactly once
ok - session start: an existing ambiguous Pi process prevents duplicate recovery
ok - session start: transient tmux unreadability never licenses a relaunch
ok - session start: the proven bare-shell recovery path remains intact
ok - session start: a confirmed Herdr husk is closed and relaunched
ok - status tail is bounded to the configured line count, with the full log path always printed
ok - orphan status logs are printed once with bounded tails
ok - tmux endpoint liveness is reported per task: alive for a live window, dead for a gone one
ok - herdr endpoint liveness is reported per task: alive for a live pane, dead for a gone one
ok - fm-session-start.sh composes the real fm-lock.sh, fm-bootstrap.sh, and fm-wake-drain.sh output verbatim
ok - compatible tasks-axi backlog rendering is compact, bounded, and preserves recovery metadata
ok - manual backlog rendering prints only title lines with hold and blocker metadata
ok - unavailable or incompatible tasks-axi falls back to compact manual backlog rendering
ok - an empty fleet reports (none) for in-flight tasks and an absent AFK flag
ok - session start emits X-mode cadence guidance in the harness supervision block
ok - next step delegates watcher ownership to the AFK daemon
ok - session start emits exactly one detected harness block and reports Pi extension load state
ok - session start preserves pi-signed primary identity while applying Pi extension guarantees
ok - session start rejects stale Pi loaded markers
ok - session start accepts current Pi markers written before lock acquisition
ok - session start rejects Pi sessions missing the turn-end guard marker
ok - session start rejects Pi loaded markers from previous sessions
FM_TEST_END 2026-07-29T10:52:22Z tests/fm-session-start.test.sh exit=0 duration_ms=86836 gate_skip=false
FM_TEST_BEGIN 2026-07-29T10:52:22Z tests/fm-sessionstart-nudge.test.sh family=session-bootstrap expected_gate_skip=none
ok - fm-sessionstart-nudge: a genuine primary gets one explicitly marked instruction line
ok - fm-sessionstart-nudge: NO_MISTAKES_GATE is silent
ok - fm-sessionstart-nudge: .no-mistakes gate common-dir is silent
ok - fm-sessionstart-nudge: an unmarked linked task worktree is silent
ok - fm-sessionstart-nudge: a marked linked secondmate home is a primary
ok - fm-sessionstart-nudge: a checkout without state is silent
ok - fm-sessionstart-nudge: a lock holder in process ancestry is already run
ok - OpenCode session.created delivers the exact wrapper nudge once per session
ok - all five verified harnesses register the shared session-start nudge
FM_TEST_END 2026-07-29T10:52:23Z tests/fm-sessionstart-nudge.test.sh exit=0 duration_ms=1341 gate_skip=false
FM_TEST_BEGIN 2026-07-29T10:52:23Z tests/fm-watcher-lock.test.sh family=watcher-wake-lock expected_gate_skip=none
ok - simultaneous watcher starts leave exactly one live process
ok - fm_pid_identity real ps fallback is locale-invariant
ok - fm_pid_identity is locale-invariant across LC_ALL/LC_TIME
ok - /proc process identity ignores simulated btime changes
ok - /proc process identity detects pid reuse
ok - MSYS /proc process identity regression skipped on non-Windows host
ok - killed watcher stale lock is reclaimed
ok - live watcher lock with stale heartbeat is actionable
ok - guard banner leads when down with pending wakes (repair-after-drain) and stays silent when fresh
ok - concurrent fm_lock_try_acquire yields exactly one winner
ok - dead-pid stale lock is reclaimed by a single acquirer
ok - concurrent stale-lock steal yields exactly one winner
ok - live steal mutex is not reclaimed
ok - live-held lock is not stolen
ok - empty mid-acquire lock keeps a minimum grace
ok - late original claimant cannot claim a recreated lock
ok - paused mid-acquire claimant backs off to active stealer
ok - watch restart refuses to signal a reused pid
ok - watch restart attaches to a verified healthy peer and later surfaces a successor gap
ok - watcher self-evicts when the lock pid no longer names it
ok - arm turns clean self-eviction without a successor into a typed failure
ok - arm attaches to a live fresh watcher and fails loudly when that cycle has no successor
ok - attached arm signals record a classified lifecycle entry
ok - arm starts+confirms a fresh watcher on a clean lock and self-heals a dead-pid lock (never healthy off a dead pid)
ok - arm cleans child watcher and temp output on HUP
ok - arm propagates an immediate watcher wake before confirmation
ok - arm attaches to a peer watcher after child stands down and surfaces a missing successor
watcher: lock held by live pid 99440 but heartbeat is stale for 838608816s (>300s); inspect or stop that watcher before re-arming.
ok - arm reports FAILED and exits non-zero when no fresh watcher can be confirmed
ok - cycle-exit ledger links a verified successor and remains size-capped
ok - SIGSTOP distinguishes live PID from stale beacon and termination records the exit class
FM_TEST_END 2026-07-29T10:54:22Z tests/fm-watcher-lock.test.sh exit=0 duration_ms=118869 gate_skip=false
FM_TEST_SUMMARY total=4 failed=0 skipped_gate=0 duration_ms=269169
FM_TEST_SUMMARY_FAMILY family=session-bootstrap count=2 duration_ms=88177 failed=0
FM_TEST_SUMMARY_FAMILY family=watcher-wake-lock count=2 duration_ms=180774 failed=0
FM_TEST_SLOWEST rank=1 script=tests/fm-watcher-lock.test.sh duration_ms=118869
FM_TEST_SLOWEST rank=2 script=tests/fm-session-start.test.sh duration_ms=86836
FM_TEST_SLOWEST rank=3 script=tests/fm-claude-stop-autoarm.test.sh duration_ms=61905
FM_TEST_SLOWEST rank=4 script=tests/fm-sessionstart-nudge.test.sh duration_ms=1341
fm-test-run: wrote timing artifact: /var/folders/04/j5yqtsfs3l527xrkvtxzy60h0000gn/T/no-mistakes-evidence/01KYPQCHDQJ4HFRF0BS419ASV3/primary-session-adjacent.test.json
Evidence: Adjacent regression timing artifact
{
  "families": [
    {
      "count": 2,
      "duration_ms": 88177,
      "failed": 0,
      "name": "session-bootstrap"
    },
    {
      "count": 2,
      "duration_ms": 180774,
      "failed": 0,
      "name": "watcher-wake-lock"
    }
  ],
  "finished_at": "2026-07-29T10:54:22Z",
  "run_id": "fm-test-run-1785322193469-24974",
  "scripts": [
    {
      "duration_ms": 61905,
      "exit": 0,
      "expected_gate_skip": "none",
      "family": "watcher-wake-lock",
      "gate_skip": false,
      "path": "tests/fm-claude-stop-autoarm.test.sh"
    },
    {
      "duration_ms": 86836,
      "exit": 0,
      "expected_gate_skip": "none",
      "family": "session-bootstrap",
      "gate_skip": false,
      "path": "tests/fm-session-start.test.sh"
    },
    {
      "duration_ms": 1341,
      "exit": 0,
      "expected_gate_skip": "none",
      "family": "session-bootstrap",
      "gate_skip": false,
      "path": "tests/fm-sessionstart-nudge.test.sh"
    },
    {
      "duration_ms": 118869,
      "exit": 0,
      "expected_gate_skip": "none",
      "family": "watcher-wake-lock",
      "gate_skip": false,
      "path": "tests/fm-watcher-lock.test.sh"
    }
  ],
  "selection": "scripts",
  "started_at": "2026-07-29T10:49:53Z",
  "summary": {
    "duration_ms": 269169,
    "failed": 0,
    "skipped_gate": 0,
    "total": 4
  }
}
Evidence: Reviewer-visible handoff behavior highlights
ok - primary-session: safe idle takeover uses stale-lock acquisition and preserves fleet state
ok - primary-session: concurrent takeover attempts admit one suspension and one successor
ok - primary-session: restore fails closed while another live primary owns the lock
ok - primary-session: archived provider reload remains recoverable and reacquires through normal session start
ok - primary-session: fleet-wide captain-action scan is read-only and omits prompt/title prose

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 2 issues found → auto-fixed ✅
  • 🚨 bin/fm-primary-session.sh:538 - The takeover eligibility check is stale by the time the provider is archived: after classify_target returns idle/paused-rate-limited, the code only performs an unvalidated inspect before writing the receipt and calling paseo agent archive. If the visible session receives input, enters running, or gains a pending permission in that window, takeover can still archive a no-longer-safe primary. Re-run the full safety classification immediately before the lifecycle call, or use a provider-side conditional archive if Paseo supports one.
  • ⚠️ bin/fm-primary-session.sh:129 - Malformed quota window data is treated as available quota. In jq, (.percentRemaining | numbers) emits no value for missing or non-numeric fields, so any(...) can return false and quota_classification reports available instead of failing unknown. Validate that the relevant fresh session/weekly windows have numeric percentRemaining before classifying, otherwise return failure so takeover refuses on unavailable quota evidence.

🔧 Fix: Captain, fail-close takeover revalidation and quota
✅ Re-checked - no issues remain.

✅ **Test** - passed

✅ No issues found.

  • bin/fm-session-start.sh
  • sed -n '1,220p' .agents/skills/primary-session-handoff/SKILL.md
  • sed -n '1,220p' docs/verification/primary-session-handoff.md
  • bin/fm-test-run.sh --json /var/folders/04/j5yqtsfs3l527xrkvtxzy60h0000gn/T/no-mistakes-evidence/01KYPQCHDQJ4HFRF0BS419ASV3/fm-primary-session.test.json tests/fm-primary-session.test.sh 2>&1 | tee /var/folders/04/j5yqtsfs3l527xrkvtxzy60h0000gn/T/no-mistakes-evidence/01KYPQCHDQJ4HFRF0BS419ASV3/fm-primary-session.test.log
  • bin/fm-test-run.sh --json /var/folders/04/j5yqtsfs3l527xrkvtxzy60h0000gn/T/no-mistakes-evidence/01KYPQCHDQJ4HFRF0BS419ASV3/primary-session-adjacent.test.json tests/fm-claude-stop-autoarm.test.sh tests/fm-session-start.test.sh tests/fm-sessionstart-nudge.test.sh tests/fm-watcher-lock.test.sh 2>&1 | tee /var/folders/04/j5yqtsfs3l527xrkvtxzy60h0000gn/T/no-mistakes-evidence/01KYPQCHDQJ4HFRF0BS419ASV3/primary-session-adjacent.test.log
  • git status --short --ignored after cleanup
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

@kunchenguid

kunchenguid commented Jul 29, 2026

Copy link
Copy Markdown
Owner

Automated reminder: thanks for the PR! This branch currently has a merge conflict with the base branch.

When you get a chance, please rebase onto (or merge) the latest base branch, resolve the conflict, and push. After that, checks will re-run and the PR will get looked at again.

Noted for firstmate#1239 at a97bf1b0.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants