Skip to content

fix: 安全恢复 Pi resume 会话锁#645

Open
codenum137 wants to merge 13 commits into
kunchenguid:mainfrom
codenum137:fm/firstmate-pi-resume-lock-fix-m9
Open

fix: 安全恢复 Pi resume 会话锁#645
codenum137 wants to merge 13 commits into
kunchenguid:mainfrom
codenum137:fm/firstmate-pi-resume-lock-fix-m9

Conversation

@codenum137

@codenum137 codenum137 commented Jul 16, 2026

Copy link
Copy Markdown

Intent

恢复并继续验证现有 Firstmate PR #645,而不是创建新的实现或 PR。以 codenum137/firstmate 的 fm/firstmate-pi-resume-lock-fix-m9 分支和 cfeab74 为权威起点,完整验证 native Pi 0.80.7 resume 场景下的会话锁安全恢复及已有测试、文档和 fail-closed 行为;允许流水线正常对齐最新 main。保持现有自然中文标题和正文内容,由 no-mistakes 流水线自身更新现有 PR #645,生成确定性的 ## Pipeline 段落和 Updates from git push no-mistakes 来源标记,并使全部 CI 变绿。不得手工编辑 PR、手工推送、强推、重写提交、创建重复 PR、合并或修改全局 Pi。

What Changed

  • 重构每个 Firstmate home 的会话锁:精确识别 harness 祖先进程,串行并原子发布所有权,仅在锁缺失或持有者已失效时接管;其他活跃持有者、畸形锁、并发抢占及无法确认的状态均 fail closed。
  • 将 Pi watcher 与 turn-end guard 统一到共享的 primary scope 和锁状态判定;resume 时重新确认锁所有权并等待 watcher 就绪,AFK 切换时安全移交监督权,失败后以退避重试恢复且隔离唤醒拒绝。
  • 增加原生 Pi 0.80.7 创建、退出、恢复的隔离 E2E 及锁和 watcher 回归覆盖,同步监督文档,并消除 composer locale 与 tmux smoke test shell 带来的不确定性。

Risk Assessment

✅ Low: Captain, the latest commit resolves the reported rejection and retry-loop risks without weakening session-lock, primary-scope, resume, or away-mode ownership invariants.

Testing

已成功的完整基线之外,本轮又验证了锁竞争与拒绝、Pi watcher/away/primary-scope、turn-end guard、三种 locale、cmux/Herdr/tmux,以及 native Pi 0.80.7 保存会话的真实离线恢复;所有检查通过。该变更没有 UI,审阅证据采用终端轨迹和持久状态转换记录。

Evidence: Native Pi resume evidence

+ PI_VERSION=0.80.7 + initial_pi_pid=92319 + initial_pi_comm=pi + resume_old_state=dead + resumed_pi_pid=93012 + resumed_pi_comm=pi BASH-ONE FIRST_PI_EXIT=0 PI_RESUME_STARTING=11111111-1111-4111-8111-111111111111 READ-ONE BASH-TWO watcher: started Pi extension arm child 2 REARMED ok - Pi 0.80.7 live E2E created, exited, resumed, reclaimed before watcher startup, woke, re-armed, and cleaned up

+ PI_VERSION=0.80.7
+ initial_pi_pid=92319
+ initial_pi_comm=pi
+ resume_old_state=dead
+ resumed_pi_pid=93012
+ resumed_pi_comm=pi
 BASH-ONE
FIRST_PI_EXIT=0
PI_RESUME_STARTING=11111111-1111-4111-8111-111111111111
 READ-ONE
 BASH-TWO
 watcher: started Pi extension arm child 2
 REARMED
ok - Pi 0.80.7 live E2E created, exited, resumed, reclaimed before watcher startup, woke, re-armed, and cleaned up
Evidence: Full native Pi E2E trace
+ set -u
+ '[' 1 '!=' 1 ']'
+++ dirname tests/fm-pi-primary-live-e2e.test.sh
++ cd tests/..
++ pwd
+ ROOT=/Users/hey137/.no-mistakes/worktrees/b58d96d90005/01KXNY8KD7QTMQFP3TGBYN8NWX
+ command -v pi
+ command -v tmux
++ command -v tmux
+ TMUX=/opt/homebrew/bin/tmux
+ SOCKET=fm-pi-live-e2e-91896
+ SESSION=pi-live-e2e
+ PI_SESSION_ID=11111111-1111-4111-8111-111111111111
++ pi --version
+ PI_VERSION=0.80.7
+ '[' 0.80.7 = 0.80.7 ']'
++ mktemp -d /var/folders/_7/702wv9kj39gcb3_wqvrzq3r40000gn/T//fm-pi-live-e2e.XXXXXX
+ LAB=/var/folders/_7/702wv9kj39gcb3_wqvrzq3r40000gn/T//fm-pi-live-e2e.zbxaFB
+ chmod 700 /var/folders/_7/702wv9kj39gcb3_wqvrzq3r40000gn/T//fm-pi-live-e2e.zbxaFB
+ umask 077
+ PROJECT=/var/folders/_7/702wv9kj39gcb3_wqvrzq3r40000gn/T//fm-pi-live-e2e.zbxaFB/project
+ HOME_DIR=/var/folders/_7/702wv9kj39gcb3_wqvrzq3r40000gn/T//fm-pi-live-e2e.zbxaFB/fmhome
+ PI_DIR=/var/folders/_7/702wv9kj39gcb3_wqvrzq3r40000gn/T//fm-pi-live-e2e.zbxaFB/pi-agent
+ PI_SESSION_DIR=/var/folders/_7/702wv9kj39gcb3_wqvrzq3r40000gn/T//fm-pi-live-e2e.zbxaFB/pi-agent/sessions
+ PI_LAUNCHER=/var/folders/_7/702wv9kj39gcb3_wqvrzq3r40000gn/T//fm-pi-live-e2e.zbxaFB/run-pi-resume.sh
+ trap cleanup EXIT
+ git clone -q /Users/hey137/.no-mistakes/worktrees/b58d96d90005/01KXNY8KD7QTMQFP3TGBYN8NWX /var/folders/_7/702wv9kj39gcb3_wqvrzq3r40000gn/T//fm-pi-live-e2e.zbxaFB/project
+ cp /Users/hey137/.no-mistakes/worktrees/b58d96d90005/01KXNY8KD7QTMQFP3TGBYN8NWX/.pi/extensions/fm-primary-pi-watch.ts /var/folders/_7/702wv9kj39gcb3_wqvrzq3r40000gn/T//fm-pi-live-e2e.zbxaFB/project/.pi/extensions/fm-primary-pi-watch.ts
+ cp /Users/hey137/.no-mistakes/worktrees/b58d96d90005/01KXNY8KD7QTMQFP3TGBYN8NWX/.pi/extensions/fm-primary-turnend-guard.ts /var/folders/_7/702wv9kj39gcb3_wqvrzq3r40000gn/T//fm-pi-live-e2e.zbxaFB/project/.pi/extensions/fm-primary-turnend-guard.ts
+ cp /Users/hey137/.no-mistakes/worktrees/b58d96d90005/01KXNY8KD7QTMQFP3TGBYN8NWX/bin/fm-primary-scope.sh /var/folders/_7/702wv9kj39gcb3_wqvrzq3r40000gn/T//fm-pi-live-e2e.zbxaFB/project/bin/fm-primary-scope.sh
+ cp /Users/hey137/.no-mistakes/worktrees/b58d96d90005/01KXNY8KD7QTMQFP3TGBYN8NWX/bin/fm-turnend-guard.sh /var/folders/_7/702wv9kj39gcb3_wqvrzq3r40000gn/T//fm-pi-live-e2e.zbxaFB/project/bin/fm-turnend-guard.sh
+ cp /Users/hey137/.no-mistakes/worktrees/b58d96d90005/01KXNY8KD7QTMQFP3TGBYN8NWX/bin/fm-supervision-instructions.sh /var/folders/_7/702wv9kj39gcb3_wqvrzq3r40000gn/T//fm-pi-live-e2e.zbxaFB/project/bin/fm-supervision-instructions.sh
+ chmod +x /var/folders/_7/702wv9kj39gcb3_wqvrzq3r40000gn/T//fm-pi-live-e2e.zbxaFB/project/bin/fm-primary-scope.sh /var/folders/_7/702wv9kj39gcb3_wqvrzq3r40000gn/T//fm-pi-live-e2e.zbxaFB/project/bin/fm-turnend-guard.sh
+ mkdir -p /var/folders/_7/702wv9kj39gcb3_wqvrzq3r40000gn/T//fm-pi-live-e2e.zbxaFB/fmhome/state /var/folders/_7/702wv9kj39gcb3_wqvrzq3r40000gn/T//fm-pi-live-e2e.zbxaFB/fmhome/config /var/folders/_7/702wv9kj39gcb3_wqvrzq3r40000gn/T//fm-pi-live-e2e.zbxaFB/pi-agent /var/folders/_7/702wv9kj39gcb3_wqvrzq3r40000gn/T//fm-pi-live-e2e.zbxaFB/pi-agent/sessions
+ cat
+ mv /var/folders/_7/702wv9kj39gcb3_wqvrzq3r40000gn/T//fm-pi-live-e2e.zbxaFB/project/bin/fm-watch-arm.sh /var/folders/_7/702wv9kj39gcb3_wqvrzq3r40000gn/T//fm-pi-live-e2e.zbxaFB/project/bin/fm-watch-arm-live-e2e-real.sh
+ cat
+ chmod +x /var/folders/_7/702wv9kj39gcb3_wqvrzq3r40000gn/T//fm-pi-live-e2e.zbxaFB/project/bin/fm-watch-arm.sh
+ cat
+ chmod +x /var/folders/_7/702wv9kj39gcb3_wqvrzq3r40000gn/T//fm-pi-live-e2e.zbxaFB/run-pi-resume.sh
+ /opt/homebrew/bin/tmux -L fm-pi-live-e2e-91896 new-session -d -s pi-live-e2e -c /var/folders/_7/702wv9kj39gcb3_wqvrzq3r40000gn/T//fm-pi-live-e2e.zbxaFB/project 'env PI_CODING_AGENT_DIR='\''/var/folders/_7/702wv9kj39gcb3_wqvrzq3r40000gn/T//fm-pi-live-e2e.zbxaFB/pi-agent'\'' PI_SESSION_DIR='\''/var/folders/_7/702wv9kj39gcb3_wqvrzq3r40000gn/T//fm-pi-live-e2e.zbxaFB/pi-agent/sessions'\'' PI_SESSION_ID='\''11111111-1111-4111-8111-111111111111'\'' FM_HOME='\''/var/folders/_7/702wv9kj39gcb3_wqvrzq3r40000gn/T//fm-pi-live-e2e.zbxaFB/fmhome'\'' FM_ROOT_OVERRIDE='\''/var/folders/_7/702wv9kj39gcb3_wqvrzq3r40000gn/T//fm-pi-live-e2e.zbxaFB/project'\'' FM_POLL=1 FM_SIGNAL_GRACE=0 FM_HEARTBEAT=600 PI_OFFLINE=1 '\''/var/folders/_7/702wv9kj39gcb3_wqvrzq3r40000gn/T//fm-pi-live-e2e.zbxaFB/run-pi-resume.sh'\'''
+ wait_for_text 'Trust project folder?' 40
+ local 'expected=Trust project folder?' attempts=40 i=0
+ '[' 0 -lt 40 ']'
+ capture
+ /opt/homebrew/bin/tmux -L fm-pi-live-e2e-91896 capture-pane -p -t pi-live-e2e -S -600
+ grep -Fq 'Trust project folder?'
+ sleep 0.5
+ i=1
+ '[' 1 -lt 40 ']'
+ capture
+ /opt/homebrew/bin/tmux -L fm-pi-live-e2e-91896 capture-pane -p -t pi-live-e2e -S -600
+ grep -Fq 'Trust project folder?'
+ sleep 0.5
+ i=2
+ '[' 2 -lt 40 ']'
+ capture
+ /opt/homebrew/bin/tmux -L fm-pi-live-e2e-91896 capture-pane -p -t pi-live-e2e -S -600
+ grep -Fq 'Trust project folder?'
+ return 0
+ /opt/homebrew/bin/tmux -L fm-pi-live-e2e-91896 send-keys -t pi-live-e2e Enter
+ wait_for_text fm-primary-turnend-guard.ts 60
+ local expected=fm-primary-turnend-guard.ts attempts=60 i=0
+ '[' 0 -lt 60 ']'
+ capture
+ /opt/homebrew/bin/tmux -L fm-pi-live-e2e-91896 capture-pane -p -t pi-live-e2e -S -600
+ grep -Fq fm-primary-turnend-guard.ts
+ sleep 0.5
+ i=1
+ '[' 1 -lt 60 ']'
+ capture
+ /opt/homebrew/bin/tmux -L fm-pi-live-e2e-91896 capture-pane -p -t pi-live-e2e -S -600
+ grep -Fq fm-primary-turnend-guard.ts
+ sleep 0.5
+ i=2
+ '[' 2 -lt 60 ']'
+ capture
+ /opt/homebrew/bin/tmux -L fm-pi-live-e2e-91896 capture-pane -p -t pi-live-e2e -S -600
+ grep -Fq fm-primary-turnend-guard.ts
+ sleep 0.5
+ i=3
+ '[' 3 -lt 60 ']'
+ capture
+ /opt/homebrew/bin/tmux -L fm-pi-live-e2e-91896 capture-pane -p -t pi-live-e2e -S -600
+ grep -Fq fm-primary-turnend-guard.ts
+ sleep 0.5
+ i=4
+ '[' 4 -lt 60 ']'
+ capture
+ /opt/homebrew/bin/tmux -L fm-pi-live-e2e-91896 capture-pane -p -t pi-live-e2e -S -600
+ grep -Fq fm-primary-turnend-guard.ts
+ sleep 0.5
+ i=5
+ '[' 5 -lt 60 ']'
+ capture
+ /opt/homebrew/bin/tmux -L fm-pi-live-e2e-91896 capture-pane -p -t pi-live-e2e -S -600
+ grep -Fq fm-primary-turnend-guard.ts
+ sleep 0.5
+ i=6
+ '[' 6 -lt 60 ']'
+ capture
+ /opt/homebrew/bin/tmux -L fm-pi-live-e2e-91896 capture-pane -p -t pi-live-e2e -S -600
+ grep -Fq fm-primary-turnend-guard.ts
+ return 0
+ wait_for_file /var/folders/_7/702wv9kj39gcb3_wqvrzq3r40000gn/T//fm-pi-live-e2e.zbxaFB/fmhome/state/.watch.lock/pid 60
+ local file=/var/folders/_7/702wv9kj39gcb3_wqvrzq3r40000gn/T//fm-pi-live-e2e.zbxaFB/fmhome/state/.watch.lock/pid attempts=60 i=0
+ '[' 0 -lt 60 ']'
+ '[' -f /var/folders/_7/702wv9kj39gcb3_wqvrzq3r40000gn/T//fm-pi-live-e2e.zbxaFB/fmhome/state/.watch.lock/pid ']'
+ return 0
++ cat /var/folders/_7/702wv9kj39gcb3_wqvrzq3r40000gn/T//fm-pi-live-e2e.zbxaFB/fmhome/state/.lock
+ initial_pi_pid=92319
++ ps -p 92319 -o comm=
+ initial_pi_comm=pi
+ '[' pi = pi ']'
++ cat /var/folders/_7/702wv9kj39gcb3_wqvrzq3r40000gn/T//fm-pi-live-e2e.zbxaFB/fmhome/state/.watch.lock/pid
+ initial_watcher_pid=92607
++ ps -p 92607 -o ppid=
++ tr -d ' '
+ initial_arm_pid=92584
+ '[' -n 92584 ']'
+ send_prompt 'Use the bash tool to run printf PI_E2E_BASH_ONE. Then reply exactly BASH-ONE.'
+ local 'prompt=Use the bash tool to run printf PI_E2E_BASH_ONE. Then reply exactly BASH-ONE.'
+ /opt/homebrew/bin/tmux -L fm-pi-live-e2e-91896 send-keys -t pi-live-e2e -l 'Use the bash tool to run printf PI_E2E_BASH_ONE. Then reply exactly BASH-ONE.'
+ /opt/homebrew/bin/tmux -L fm-pi-live-e2e-91896 send-keys -t pi-live-e2e Enter
+ wait_for_exact_line BASH-ONE
+ local expected=BASH-ONE attempts=120 i=0
+ '[' 0 -lt 120 ']'
+ capture
+ /opt/homebrew/bin/tmux -L fm-pi-live-e2e-91896 capture-pane -p -t pi-live-e2e -S -600
+ grep -Fxq ' BASH-ONE'
+ sleep 0.5
+ i=1
+ '[' 1 -lt 120 ']'
+ capture
+ /opt/homebrew/bin/tmux -L fm-pi-live-e2e-91896 capture-pane -p -t pi-live-e2e -S -600
+ grep -Fxq ' BASH-ONE'
+ return 0
++ find /var/folders/_7/702wv9kj39gcb3_wqvrzq3r40000gn/T//fm-pi-live-e2e.zbxaFB/pi-agent/sessions -maxdepth 1 -type f -name '*_11111111-1111-4111-8111-111111111111.jsonl'
++ head -1
+ session_file=/var/folders/_7/702wv9kj39gcb3_wqvrzq3r40000gn/T//fm-pi-live

... [21522 bytes truncated] ...

lls]
  afk, ask-matt, axi, bearings, bootstrap-diagnostics, brandkit,
codebase-design, codex-image2, decision-hold-lifecycle, design-taste-frontend,
design-taste-frontend-v1, diagnosing-bugs, diagnostic-reasoning,
domain-modeling, find-skills, firstmate-codexapp, firstmate-coding-guidelines,
firstmate-orca, fmx-respond, frontend-design, full-output-enforcement, gnhf,
gpt-taste, grill-me, grill-with-docs, grilling, handoff, harness-adapters,
high-end-visual-design, hv-analysis, image-to-code, imagegen-frontend-mobile,
imagegen-frontend-web, implement, improve-codebase-architecture,
industrial-brutalist-ui, lark-approval, lark-apps, lark-attendance, lark-base,
lark-calendar, lark-contact, lark-doc, lark-drive, lark-event, lark-im,
lark-mail, lark-markdown, lark-minutes, lark-note, lark-okr,
lark-openapi-explorer, lark-shared, lark-sheets, lark-skill-maker, lark-slides,
lark-task, lark-vc, lark-vc-agent, lark-whiteboard, lark-wiki,
lark-workflow-meeting-summary, lark-workflow-standup-report, last30days, lavish,
librarian, minimalist-ui, no-mistakes, pi-subagents, project-management,
prototype, redesign-existing-projects, secondmate-provisioning,
setup-matt-pocock-skills, stitch-design-taste, stow, stuck-crewmate-recovery,
tdd, teach, to-issues, to-prd, triage, updatefirstmate, writing-great-skills,
zenith

[Extensions]
  fm-live-e2e-provider.ts, fm-primary-pi-watch.ts, fm-primary-turnend-guard.ts


 Use the bash tool to run printf PI_E2E_BASH_ONE. Then reply exactly BASH-ONE.



 $ printf PI_E2E_BASH_ONE

 PI_E2E_BASH_ONE

 Took 0.5s


 BASH-ONE

────────────────────────────────────────────────────────────────────────────────

────────────────────────────────────────────────────────────────────────────────
/private/var/folders/_7/702wv9kj39gcb3_wqvrzq3r40000gn/T/fm-pi-live-e2e.zbxaF...
↑23k ↓13 R21k W23k CH93.2% 70.6%/32k (auto)                          fm-live-e2e

To resume this session: pi --session-dir /var/folders/_7/702wv9kj39gcb3_wqvrzq3r
40000gn/T//fm-pi-live-e2e.zbxaFB/pi-agent/sessions --session 11111111-1111-4111-
8111-111111111111
FIRST_PI_EXIT=0
PI_RESUME_STARTING=11111111-1111-4111-8111-111111111111

 pi v0.80.7
 escape interrupt · ctrl+c/ctrl+d clear/exit · / commands · ! bash · ctrl+o
 more
 Press ctrl+o to show full startup help and loaded resources.

 Pi can explain its own features and look up its docs. Ask it how to use or
 extend Pi.


[Context]
  AGENTS.md

[Skills]
  afk, ask-matt, axi, bearings, bootstrap-diagnostics, brandkit,
codebase-design, codex-image2, decision-hold-lifecycle, design-taste-frontend,
design-taste-frontend-v1, diagnosing-bugs, diagnostic-reasoning,
domain-modeling, find-skills, firstmate-codexapp, firstmate-coding-guidelines,
firstmate-orca, fmx-respond, frontend-design, full-output-enforcement, gnhf,
gpt-taste, grill-me, grill-with-docs, grilling, handoff, harness-adapters,
high-end-visual-design, hv-analysis, image-to-code, imagegen-frontend-mobile,
imagegen-frontend-web, implement, improve-codebase-architecture,
industrial-brutalist-ui, lark-approval, lark-apps, lark-attendance, lark-base,
lark-calendar, lark-contact, lark-doc, lark-drive, lark-event, lark-im,
lark-mail, lark-markdown, lark-minutes, lark-note, lark-okr,
lark-openapi-explorer, lark-shared, lark-sheets, lark-skill-maker, lark-slides,
lark-task, lark-vc, lark-vc-agent, lark-whiteboard, lark-wiki,
lark-workflow-meeting-summary, lark-workflow-standup-report, last30days, lavish,
librarian, minimalist-ui, no-mistakes, pi-subagents, project-management,
prototype, redesign-existing-projects, secondmate-provisioning,
setup-matt-pocock-skills, stitch-design-taste, stow, stuck-crewmate-recovery,
tdd, teach, to-issues, to-prd, triage, updatefirstmate, writing-great-skills,
zenith

[Extensions]
  fm-live-e2e-provider.ts, fm-primary-pi-watch.ts, fm-primary-turnend-guard.ts


 Use the bash tool to run printf PI_E2E_BASH_ONE. Then reply exactly BASH-ONE.

+ grep -Fq '$ bin/fm-watch-arm.sh'


 $ printf PI_E2E_BASH_ONE

 PI_E2E_BASH_ONE


 BASH-ONE


 Use the read tool to read the first five lines of README.md. Then reply
 exactly READ-ONE.



 read README.md:1-5


 READ-ONE


 Use the bash tool to run printf PI_E2E_BASH_TWO. Then reply exactly BASH-TWO.



 $ printf PI_E2E_BASH_TWO

 PI_E2E_BASH_TWO

 Took 0.0s


 BASH-TWO


 Reply exactly READY with no tools. After any FIRSTMATE WATCHER WAKE, run
 bin/fm-wake-drain.sh, read the signaled status, call fm_watch_arm_pi to
 re-arm, and finish exactly REARMED.


 READY


 FIRSTMATE WATCHER WAKE: signal:
 /var/folders/_7/702wv9kj39gcb3_wqvrzq3r40000gn/T//fm-pi-live-e2e.zbxaFB/fmhome
 /state/pi-e2e.status

 Run bin/fm-wake-drain.sh first, handle the queued wake, then resume Pi
 supervision.



 $ bin/fm-wake-drain.sh

 1784269101   2   signal   pi-e2e.status   signal:
 /var/folders/_7/702wv9kj39gcb3_wqvrzq3r40000gn/T//fm-pi-live-e2e.zbxaFB/fmhome
 /state/pi-e2e.status

 Took 1.2s



 fm_watch_arm_pi
 watcher: started Pi extension arm child 2


 REARMED

────────────────────────────────────────────────────────────────────────────────

────────────────────────────────────────────────────────────────────────────────
/private/var/folders/_7/702wv9kj39gcb3_wqvrzq3r40000gn/T/fm-pi-live-e2e.zbxaF...
↑50k ↓59 R170k W50k CH93.2% 71.7%/32k (auto)                         fm-live-e2e'
+ wait_for_file /var/folders/_7/702wv9kj39gcb3_wqvrzq3r40000gn/T//fm-pi-live-e2e.zbxaFB/fmhome/state/.watch.lock/pid 60
+ local file=/var/folders/_7/702wv9kj39gcb3_wqvrzq3r40000gn/T//fm-pi-live-e2e.zbxaFB/fmhome/state/.watch.lock/pid attempts=60 i=0
+ '[' 0 -lt 60 ']'
+ '[' -f /var/folders/_7/702wv9kj39gcb3_wqvrzq3r40000gn/T//fm-pi-live-e2e.zbxaFB/fmhome/state/.watch.lock/pid ']'
+ return 0
+ pid_file=/var/folders/_7/702wv9kj39gcb3_wqvrzq3r40000gn/T//fm-pi-live-e2e.zbxaFB/fmhome/state/.watch.lock/pid
++ sed -n 1p /var/folders/_7/702wv9kj39gcb3_wqvrzq3r40000gn/T//fm-pi-live-e2e.zbxaFB/fmhome/state/.watch.lock/pid
+ watcher_pid=93746
++ ps -p 93746 -o ppid=
++ tr -d ' '
+ arm_pid=93725
+ '[' -n 93725 ']'
+ /opt/homebrew/bin/tmux -L fm-pi-live-e2e-91896 send-keys -t pi-live-e2e -l /quit
+ sleep 1
+ /opt/homebrew/bin/tmux -L fm-pi-live-e2e-91896 send-keys -t pi-live-e2e Enter
+ wait_for_text RESUMED_PI_EXIT=0 60
+ local expected=RESUMED_PI_EXIT=0 attempts=60 i=0
+ '[' 0 -lt 60 ']'
+ capture
+ /opt/homebrew/bin/tmux -L fm-pi-live-e2e-91896 capture-pane -p -t pi-live-e2e -S -600
+ grep -Fq RESUMED_PI_EXIT=0
+ sleep 0.5
+ i=1
+ '[' 1 -lt 60 ']'
+ capture
+ /opt/homebrew/bin/tmux -L fm-pi-live-e2e-91896 capture-pane -p -t pi-live-e2e -S -600
+ grep -Fq RESUMED_PI_EXIT=0
+ return 0
+ wait_pid_dead 93012
+ local pid=93012 i=0
+ '[' 0 -lt 50 ']'
+ kill -0 93012
+ return 0
+ wait_pid_dead 93746
+ local pid=93746 i=0
+ '[' 0 -lt 50 ']'
+ kill -0 93746
+ return 0
+ wait_pid_dead 93725
+ local pid=93725 i=0
+ '[' 0 -lt 50 ']'
+ kill -0 93725
+ return 0
+ printf 'ok - Pi %s live E2E created, exited, resumed, reclaimed before watcher startup, woke, re-armed, and cleaned up\n' 0.80.7
ok - Pi 0.80.7 live E2E created, exited, resumed, reclaimed before watcher startup, woke, re-armed, and cleaned up
+ cleanup
+ local pid_file watcher_pid arm_pid
++ find /var/folders/_7/702wv9kj39gcb3_wqvrzq3r40000gn/T//fm-pi-live-e2e.zbxaFB/fmhome/state -maxdepth 3 -type f -name pid
++ head -1
+ pid_file=
+ watcher_pid=
+ arm_pid=
+ '[' -n '' ']'
+ /opt/homebrew/bin/tmux -L fm-pi-live-e2e-91896 kill-server
+ sleep 0.1
+ '[' -n '' ']'
+ '[' -n '' ']'
+ rm -rf /var/folders/_7/702wv9kj39gcb3_wqvrzq3r40000gn/T//fm-pi-live-e2e.zbxaFB
Evidence: Session lock contract
ok - native Pi comm and argv are classified as a live holder
ok - all supported native harness names match exactly and substrings do not
ok - a verified live other holder remains byte-for-byte unchanged
ok - same-holder acquisition and ownership inspection are idempotent
ok - a dead holder is reclaimed through the production owner
ok - malformed lock content fails closed without mutation
ok - acquisition without a verified harness ancestor fails closed
ok - bare interpreter classification uses only the exact script basename
ok - serialized concurrent contenders produce exactly one winner
Evidence: Pi watcher integration
ok - Pi primary watcher extension is tracked, self-hashing, and self-locating
ok - Pi secondmate launch wiring includes both tracked primary extensions
ok - Pi extension reports external healthy watcher output
ok - Pi custom tool returns text content and structured details
ok - Pi custom tool waits for verified watcher readiness
ok - Pi process-exit cleanup listener has a bounded lifecycle
ok - Pi process-exit cleanup stops the attached arm child
ok - Pi watcher transfers its active arm and restores rollback supervision
ok - Pi watcher contains delivery rejection and backs off monitor failures
ok - Pi session_start reclaims safe locks, preserves away ownership, refuses live other, and idempotently arms
ok - Pi watcher stays inert in linked task worktrees and arms valid secondmate homes
ok - Pi watcher distinguishes verified competitor, acquisition failure, and changed ownership
ok - OpenCode primary watcher plugin has the verified TUI wake wiring
ok - OpenCode plugins have an explicit ESM boundary even under a typeless parent package
ok - OpenCode watcher plugin uses the effective FM_HOME state
ok - OpenCode watcher plugin sources the effective config
ok - OpenCode watcher plugin requires session lock ownership
ok - OpenCode watcher coordinator respects primary scope
ok - OpenCode watcher plugin rearms after a watcher wake
ok - OpenCode watcher plugin coordinates with the turn-end guard
ok - OpenCode healthy arm output does not suppress the turn-end guard
Evidence: Composer locale matrix

locale=C bare-claude=empty bare-codex=empty ghost=empty typed=pending shell=unknown locale=C.UTF-8 bare-claude=empty bare-codex=empty ghost=empty typed=pending shell=unknown locale=en_US.UTF-8 bare-claude=empty bare-codex=empty ghost=empty typed=pending shell=unknown

locale=C bare-claude=empty bare-codex=empty ghost=empty typed=pending shell=unknown
locale=C.UTF-8 bare-claude=empty bare-codex=empty ghost=empty typed=pending shell=unknown
locale=en_US.UTF-8 bare-claude=empty bare-codex=empty ghost=empty typed=pending shell=unknown
Evidence: cmux composer suite
ok - fm_backend_cmux_version_check: accepts the verified minimum (0.64.17)
ok - fm_backend_cmux_version_check: accepts a newer version (0.70.0)
ok - fm_backend_cmux_version_check: refuses an old version loudly
ok - fm_backend_cmux_version_check: refuses loudly when cmux is not found on PATH or at the bundle path
ok - fm_backend_cmux_password: reads the first non-empty line of config/cmux-socket-password
ok - fm_backend_cmux_password: preserves spaces and tabs in config/cmux-socket-password
ok - fm_backend_cmux_password: respects FM_CONFIG_OVERRIDE
ok - fm_backend_cmux_password: empty when config/cmux-socket-password is absent
ok - fm_backend_cmux_cli: exports CMUX_SOCKET_PASSWORD when config/cmux-socket-password is set
ok - fm_backend_cmux_parse_target: splits '<workspace_uuid>:<surface_uuid>' on the first colon
ok - fm_backend_cmux_normalize_key: Enter/Escape/C-c map to cmux's verified enter/escape/ctrl-c
ok - fm_backend_cmux_scoped_title: scopes a primary task title with firstmate plus root hash
ok - fm_backend_cmux_scoped_title: scopes a secondmate task title with the home marker plus root hash
ok - fm_backend_cmux_scoped_title: includes the resolved FM_ROOT hash in the home label
ok - fm_backend_validate: cmux is a known backend
ok - fm_backend_busy_state: cmux (no native primitive) always reports unknown, same as tmux/zellij/orca
ok - fm_backend_composer_state: routes cmux to the cmux composer classifier
ok - fm_backend_cmux_ping_state: reports 'ok' on PONG
ok - fm_backend_cmux_ping_state: reports 'denied' when socketControlMode=cmuxOnly rejects the connection
ok - fm_backend_cmux_ping_state: reports 'unauth' when password mode rejects a missing/wrong password
ok - fm_backend_cmux_ping_state: reports 'unauth' when password mode rejects a wrong password (Invalid password)
ok - fm_backend_cmux_ping_state: reports 'down' when the app is not running yet
ok - fm_backend_cmux_ensure_running: returns immediately when cmux is already reachable
ok - fm_backend_cmux_ensure_running: fails fast on a denied socket without attempting to launch, naming every viable mode
ok - fm_backend_cmux_ensure_running: fails fast on an unauthenticated socket, naming the password config and the Automation mode alternative
ok - fm_backend_cmux_create_task: refuses a duplicate workspace title (cmux's own new-workspace has no uniqueness check)
ok - fm_backend_cmux_create_task: creates a workspace and parses workspace_id/surface_id from list responses
ok - fm_backend_cmux_target_ready: fails when the workspace/surface is not found (list-panes structural check)
ok - fm_backend_cmux_target_ready: verifies the workspace title against the expected label first
ok - fm_backend_cmux_target_ready: rejects a workspace id reused under a different title
ok - fm_backend_cmux_capture: fetches generously and trims to N lines locally
ok - fm_backend_cmux_capture: propagates a read-screen failure even when stdout is empty
ok - fm_backend_cmux_capture: fails when the target surface is absent
ok - fm_backend_cmux_send_key: normalizes the key (Escape -> escape) and targets the explicit workspace/surface
ok - fm_backend_cmux_send_key: recovers stale workspace/surface ids by expected label
ok - fm_backend_cmux_send_literal: calls send with an explicit workspace/surface and a -- separator
ok - fm_backend_cmux_current_path: actively probes with marked begin/end lines (zellij-shape frozen cwd)
ok - fm_backend_cmux_composer_state: a bare '❯' composer row reads empty
ok - fm_backend_cmux_composer_state: the ghost placeholder text reads empty, not pending
ok - fm_backend_cmux_composer_state: real composer text reads pending
ok - fm_backend_cmux_composer_state: a slash-command popup's argument-hint placeholder still reads pending (the incident fix)
ok - fm_backend_cmux_composer_state: reports unknown when the surface cannot be captured
ok - fm_backend_cmux_composer_state: reports unknown when no border-delimited composer row is found
ok - fm_backend_cmux_send_text_submit: reports 'empty' once the composer row reads empty after one Enter
ok - fm_backend_cmux_send_text_submit: reports 'pending' when the composer never clears after retried Enters (swallowed)
ok - fm_backend_cmux_send_text_submit: retries past a popup-placeholder-fill Enter and lands the real second Enter (the incident fix)
ok - fm_backend_cmux_send_text_submit: reports 'send-failed' when the target workspace/surface is absent
ok - fm_backend_cmux_window_of_workspace: walks windows and counts the membership-confirming workspace list
ok - fm_backend_cmux_window_of_workspace: echoes nothing when no window holds the workspace
ok - fm_backend_cmux_kill: closes the task workspace directly when it is not the last in its window
ok - fm_backend_cmux_kill: adds a throwaway sibling then closes the target when it is the last workspace in its window
ok - fm_backend_cmux_kill: never fails even when close-workspace fails
ok - fm_backend_cmux_kill: recovers stale workspace/surface ids by expected label
ok - fm_backend_cmux_list_live: lists only this home's scoped task workspaces using plain fm-<id> labels
ok - fm-spawn.sh: refuses backend=cmux for --secondmate spawns (mirrors Orca's refusal; no secondmate launch design exists yet)
- Evidence: Herdr composer suite (local file: /var/folders/_7/702wv9kj39gcb3_wqvrzq3r40000gn/T/no-mistakes-evidence/01KXNY8KD7QTMQFP3TGBYN8NWX/herdr-composer-suite.log)
Evidence: Real tmux smoke test
@1
ok - real tmux: fm_backend_tmux_create_task creates a window and refuses a duplicate
ok - real tmux: fm_backend_tmux_send_text_line sends literal text and submits with Enter
ok - real tmux: fm_backend_tmux_send_literal + fm_backend_tmux_send_key Enter submit as two separate steps
ok - real tmux: fm_backend_tmux_capture's -S -N bound trims old history for a small window and reaches it for a large one
ok - real tmux: fm_backend_tmux_resolve_bare_selector (list-live) finds the created window by name
ok - real tmux: fm_backend_tmux_resolve_bare_selector fails for a window that does not exist
ok - real tmux: fm_backend_tmux_kill removes the window and is idempotent/best-effort
Evidence: Turn-end guard suite
ok - fm_supervision_unhealthy: false with no state/*.meta at all
ok - fm_supervision_unhealthy: true with in-flight task and no beacon ever
ok - fm_supervision_unhealthy: true with in-flight task and a beacon far outside the grace window
ok - fm_supervision_unhealthy: false with in-flight task and a fresh beacon
ok - fm_supervision_status: FM_SUP_QUEUE_PENDING tracks state/.wake-queue
ok - fm-turnend-guard: silent no-op with nothing in flight
ok - fm-turnend-guard: blocks when a fresh beacon has no live watcher lock
ok - fm-turnend-guard: blocks on a dead watcher lock even when the beacon is fresh
ok - fm-turnend-guard: silent no-op with a live watcher lock and fresh beacon
ok - fm-turnend-guard: blocks on a live watcher lock with an ancient beacon
ok - fm-turnend-guard: blocks with the exact required reason in the primary when unhealthy
ok - fm-turnend-guard: blocks from active FM_HOME state, not only repo-root state
ok - fm-turnend-guard: X-mode repair reason sources the cadence config
ok - fm-turnend-guard: ignores stale repo-root state when FM_HOME is set
ok - fm-turnend-guard: uses FM_STATE_OVERRIDE ahead of FM_HOME/state
ok - fm-turnend-guard: stop_hook_active=true always allows the stop (never blocks twice in one turn)
ok - fm-turnend-guard: blocks a blind turn end in a secondmate's own home (.fm-secondmate-home no longer excludes it)
ok - fm-turnend-guard: idle-by-default - silent in a secondmate home with nothing in flight
ok - fm-turnend-guard: stop_hook_active=true allows the stop in a secondmate home (never blocks twice in one turn)
ok - fm-turnend-guard: secondmate deferred-death recovery - silent while watched, forces re-arm once the watcher exits
ok - fm-turnend-guard: inert in a secondmate's own child worktree (linked git worktree) even when unhealthy
ok - fm-turnend-guard: blocks a blind turn end in a treehouse-leased LINKED secondmate home (marker force-include)
ok - fm-turnend-guard: an invalid (empty) marker cannot spoof inclusion; linked worktree stays exempt
ok - fm-turnend-guard: a non-ASCII marker cannot spoof inclusion; linked worktree stays exempt
ok - fm-turnend-guard: inert in a crewmate/scout task worktree (linked git worktree) even when unhealthy
ok - fm-turnend-guard: fails open (never blocks) when jq is missing
ok - fm-turnend-guard: silent no-op on empty stdin
ok - fm-turnend-guard: runs well under the generous timing margin (0s)
ok - fm-turnend-guard-grok: forces one same-session resume when the shared predicate blocks
ok - fm-turnend-guard-grok: loop guard prevents a nested resume loop
ok - .claude/settings.json: Stop hook uses CLAUDE_PROJECT_DIR-anchored command
ok - .codex/hooks.json: Stop hook invokes the shared primary guard
ok - .codex/hooks.json: Stop hook uses hook process root when payload cwd is outside
ok - .codex/hooks.json: Stop hook ignores nested git root guard scripts
ok - .opencode primary plugin: session.idle forces one follow-up through the shared guard
ok - .opencode primary plugin: guard path is anchored to worktree, not directory
ok - .pi primary extension: agent_settled forces one follow-up through the shared guard
ok - .pi primary extension: no-tool and multi-tool runs each inject exactly one guard follow-up
ok - .pi primary extension: delivery failure resets the logical-run latch
ok - .grok primary hook: Stop hook invokes the grok adapter
- Outcome: 🔧 1 issue found → auto-fixed (2) ✅ across 3 runs (2h28m57s)

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 3 issues found → auto-fixed (5) ✅
  • 🚨 tests/fm-pi-primary-live-e2e.test.sh:122 - Authoritative criterion “完整验证 native Pi 0.80.7 resume 场景下的会话锁安全恢复” is not exercised: this opt-in E2E still launches plain pi, while the other coverage only invokes a mocked session_start callback from Node. Add a real Pi 0.80.7 create/exit/resume flow that leaves the old native PID in the lock, resumes the saved session, and verifies reclamation before watcher startup.
  • 🚨 .pi/extensions/fm-primary-pi-watch.ts:156 - A resumed session calls startArm() even when state/.afk exists. Its --restart arm terminates the daemon-owned watcher and routes routine wakes directly into Pi, violating away mode's single-owner batching contract. Recover the session lock independently, but do not spawn or restart a watcher while away mode is active.
  • ⚠️ .pi/extensions/fm-primary-pi-watch.ts:95 - Missing/dead locks are acquired without first verifying that this is a main or valid secondmate home. Since tracked Pi extensions also auto-load in trusted linked task worktrees, a crewmate/scout can create its own session lock and watcher, or act on an inherited supervisor home. Apply the existing primary-home scope predicate before recovery and arming.

🔧 Fix: Validate native Pi resume lock recovery end-to-end
1 error still open:

  • 🚨 tests/fm-pi-primary-live-e2e.test.sh:139 - The live E2E copies the real auth.json into $ROOT/.pi-live-e2e.$$, but that path is not ignored. A SIGKILL or host crash bypasses the EXIT cleanup and leaves credentials as untracked repository content that can be accidentally staged. Create LAB with a mode-0700 mktemp -d under the system temporary directory before copying authentication data.

🔧 Fix: Harden Pi resume scope, readiness, and isolated E2E
1 error still open:

  • 🚨 .pi/extensions/fm-primary-pi-watch.ts:136 - The .afk check only prevents a new arm; it does not stop an arm child created earlier by session_start. When /afk is entered during a normal Pi session, the daemon therefore encounters the existing watcher singleton, while that extension-owned watcher sends the next wake directly to Pi instead of through away-mode batching. Transfer watcher ownership during AFK entry and cover the active-arm-to-AFK transition.

🔧 Fix: Transfer Pi watcher ownership on away entry
2 errors still open:

  • 🚨 .pi/extensions/fm-primary-pi-watch.ts:108 - Away reconciliation is one-way: it stops the Pi arm when .afk appears but never restores it when the launch transaction removes the flag. fm-afk-launch.sh start writes .afk before daemon creation and removes it on terminal/readiness failure, so rollback can leave normal mode with neither daemon nor extension watcher; the intentional-stop marker also suppresses the child's close notification. Re-arm after a rolled-back handoff, or move stop/reap into the launch transaction so rollback restores supervision.
  • 🚨 .pi/extensions/fm-primary-pi-watch.ts:113 - The active-child handoff depends on this fs.watch, but construction is unguarded and the error handler permanently disables monitoring without reconciling ownership. If an error occurs while .afk exists, its failure wake is suppressed by sendWake and the active extension arm can remain, recreating the daemon singleton collision with no alert. Catch setup failures and make monitor errors retry or stop the arm fail-closed before advertising the extension as loaded.

🔧 Fix: Harden Pi AFK monitor rollback and failure recovery
2 issues (1 error, 1 warning) still open:

  • 🚨 .pi/extensions/fm-primary-pi-watch.ts:183 - Both monitor-recovery branches discard the sendWake() promise. If Pi rejects follow-up delivery during shutdown or another lifecycle race, that rejection is detached from the surrounding startArm() chain and becomes unhandled, unlike the guarded child close/error paths. Return or catch these promises through a shared safe-delivery helper.
  • ⚠️ .pi/extensions/fm-primary-pi-watch.ts:154 - A persistent monitor error retries every 250 ms without backoff. Each attempt calls isPrimaryHome(), which synchronously spawns the scope script and its Git probes, causing four blocking subprocess trees per second indefinitely in the Pi process. Use capped exponential backoff and avoid rechecking invariant primary scope on every retry while remaining fail-closed.

🔧 Fix: Contain Pi wake rejection and back off monitor retries
✅ Re-checked - no issues remain.

🔧 **Test** - 1 issue found → auto-fixed (2) ✅
  • 🚨 tests failed with exit code 1
  • command -v tmux >/dev/null || { echo "tmux is required for e2e tests" >&2; exit 1; }; tmux -V; rc=0; for t in tests/*.test.sh; do echo "== $t =="; bash "$t" || rc=1; done; exit "$rc"

🔧 Fix: Pin tmux smoke test to Bash
1 error still open:

  • 🚨 tests failed with exit code 1
  • command -v tmux >/dev/null || { echo "tmux is required for e2e tests" >&2; exit 1; }; tmux -V; rc=0; for t in tests/*.test.sh; do echo "== $t =="; bash "$t" || rc=1; done; exit "$rc"

🔧 Fix: Fix locale-dependent composer classification
✅ Re-checked - no issues remain.

  • command -v tmux >/dev/null || { echo "tmux is required for e2e tests" >&2; exit 1; }; tmux -V; rc=0; for t in tests/*.test.sh; do echo "== $t =="; bash "$t" || rc=1; done; exit "$rc"
  • 已成功的配置基线:command -v tmux &gt;/dev/null || { echo &#34;tmux is required for e2e tests&#34; &gt;&amp;2; exit 1; }; tmux -V; rc=0; for t in tests/*.test.sh; do echo &#34;== $t ==&#34;; bash &#34;$t&#34; || rc=1; done; exit &#34;$rc&#34;
  • bash tests/fm-session-lock.test.sh
  • bash tests/fm-pi-watch-extension.test.sh
  • bash tests/fm-turnend-guard.test.sh
  • bash tests/fm-composer-lib.test.sh
  • bash tests/fm-backend-cmux.test.sh
  • bash tests/fm-backend-herdr.test.sh
  • bash tests/fm-backend-tmux-smoke.test.sh
  • FM_PI_LIVE_E2E=1 bash -x tests/fm-pi-primary-live-e2e.test.sh
  • CC.UTF-8en_US.UTF-8 下直接验证 composer 分类矩阵
  • 隔离 clone 后运行 git rev-parse HEAD,确认 E2E 来源为 33341f6d16a2a08382872c7d8a08799dcff10abe
  • 清理生成状态后运行 git status --short --branch 并确认无残留测试目录
🔧 **Document** - 1 issue found → auto-fixed (2) ✅
  • ⚠️ bin/fm-session-start.sh:252 - fm-lock.sh can now fail for malformed locks, concurrent acquisition, missing harness ancestry, or publication errors, but this banner labels every failure as another live session holding the lock. The detailed error remains visible, but the headline contradicts the corrected AGENTS.md guidance. Executable wording is outside this documentation-only pass.

🔧 Fix: Clarify unowned session lock refusal documentation
1 warning still open:

  • ⚠️ bin/fm-session-start.sh:252 - fm-lock.sh can refuse malformed locks, concurrent acquisition, missing harness ancestry, or publication errors without a live holder, but this banner and subsequent read-only helper messages still assert that another session owns the lock. Correcting those executable output strings is outside this documentation-only pass.

🔧 Fix: Clarify session lock refusal output
✅ Re-checked - no issues remain.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

@codenum137 codenum137 changed the title fix: 安全恢复 Pi resume 场景下的会话锁 修复 Pi resume 场景下的会话锁安全恢复 Jul 16, 2026
@codenum137 codenum137 closed this Jul 16, 2026
@codenum137 codenum137 reopened this Jul 16, 2026
@codenum137
codenum137 force-pushed the fm/firstmate-pi-resume-lock-fix-m9 branch from cfeab74 to d4aed08 Compare July 17, 2026 07:03
@codenum137 codenum137 changed the title 修复 Pi resume 场景下的会话锁安全恢复 fix: 安全恢复 Pi resume 会话锁 Jul 17, 2026
@kunchenguid

kunchenguid commented Jul 17, 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#645 at 88118f14.

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