diff --git a/config/architecture-policies.jsonc b/config/architecture-policies.jsonc index db623ef0922a..c6566d884f56 100644 --- a/config/architecture-policies.jsonc +++ b/config/architecture-policies.jsonc @@ -49,6 +49,10 @@ "type": "change-scope", "comparison": "worktree", "whenChanged": [ + "src/main/mobile-terminal-surface-recovery/**", + "src/main/ipc/pty/spawn-operation.ts", + "src/main/ipc/pty/pty-dead-owner-respawn.test.ts", + "src/renderer/src/components/terminal-pane/pty-connection-cold-restore-resume-command.spec.ts", "src/renderer/src/mobile-terminal-surface-recovery/**", "docs/issue/手机打开工作区终端身份恢复/**" ], @@ -76,6 +80,10 @@ "src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-live-bookmark.test.ts", "tests/e2e/mobile-terminal-surface-recovery.spec.ts", + "src/main/mobile-terminal-surface-recovery/**", + "src/main/ipc/pty/spawn-operation.ts", + "src/main/ipc/pty/pty-dead-owner-respawn.test.ts", + "src/renderer/src/components/terminal-pane/pty-connection-cold-restore-resume-command.spec.ts", "src/renderer/src/mobile-terminal-surface-recovery/**", "src/renderer/src/lib/worktree-agent-live-surface-adoption.ts", "src/renderer/src/lib/worktree-agent-activation-gate.ts", @@ -273,6 +281,10 @@ "src/renderer/src/store/terminals/workspace-terminal-hydration.ts", "src/renderer/src/store/repos/all-host-repo-catalog-actions.ts", "src/renderer/src/store/repos/runtime-repo-catalog-actions.ts", + "src/main/mobile-terminal-surface-recovery/**", + "src/main/ipc/pty/spawn-operation.ts", + "src/main/ipc/pty/pty-dead-owner-respawn.test.ts", + "src/renderer/src/components/terminal-pane/pty-connection-cold-restore-resume-command.spec.ts", "src/renderer/src/mobile-terminal-surface-recovery/**", "tests/e2e/mobile-terminal-surface-recovery.spec.ts", "src/renderer/src/lib/worktree-agent-live-surface-adoption.ts", diff --git a/config/fork-features.jsonc b/config/fork-features.jsonc index 2aff60dd375c..3e17bb2081af 100644 --- a/config/fork-features.jsonc +++ b/config/fork-features.jsonc @@ -97,6 +97,7 @@ ], "policyId": "mobile-terminal-surface-recovery", "ownedPaths": [ + "src/main/mobile-terminal-surface-recovery/**", "src/renderer/src/mobile-terminal-surface-recovery/**", "tests/e2e/mobile-terminal-surface-recovery.spec.ts", "docs/issue/手机打开工作区终端身份恢复/**" @@ -111,6 +112,21 @@ "src/renderer/src/mobile-terminal-surface-recovery/live-terminal-placement-verdict.ts" ], "seams": [ + { + "file": "src/main/ipc/pty/spawn-operation.ts", + "mustContain": "assertNumericReattachMaySpawn", + "why": "A numeric restore without a stable owner cannot become a fresh local or SSH shell without host-proven absence." + }, + { + "file": "src/main/ipc/pty/pty-dead-owner-respawn.test.ts", + "mustContain": "hasOwner", + "why": "Exercise numeric live, positive absence and missing-owner requests through the actual IPC handler." + }, + { + "file": "src/renderer/src/components/terminal-pane/pty-connection-cold-restore-resume-command.spec.ts", + "mustContain": "keeps a live local reattach", + "why": "A hibernation note and empty replay cannot retire a live process." + }, { "file": "src/main/runtime/workspaces/workspace-activation.ts", "mustContain": "resolveWorkspaceSelector(worktreeSelector)", @@ -226,6 +242,8 @@ } ], "tests": [ + "src/main/ipc/pty/pty-dead-owner-respawn.test.ts", + "src/renderer/src/components/terminal-pane/pty-connection-restore-and-resume.test.ts", "src/renderer/src/hooks/ipc-events/worktree-event-runtime-activation.test.ts", "src/renderer/src/hooks/ipc-events/worktree-event-lineage-refresh.test.ts", @@ -241,7 +259,7 @@ "src/renderer/src/lib/worktree-agent-activation-gate.test.ts" ], "checks": [ - "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/project-group-folder-workspace-selectors.test.ts src/renderer/src/hooks/ipc-events/worktree-event-runtime-activation.test.ts src/renderer/src/hooks/ipc-events/worktree-event-lineage-refresh.test.ts src/renderer/src/mobile-terminal-surface-recovery src/renderer/src/lib/worktree-agent-activation-gate.test.ts src/renderer/src/lib/worktree-agent-activation-seam.test.ts src/renderer/src/components/terminal-pane/pty-connection/reattach-success-settle.test.ts src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-newer-daemon-hold.test.ts src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-live-bookmark.test.ts" + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty/pty-dead-owner-respawn.test.ts src/renderer/src/components/terminal-pane/pty-connection-restore-and-resume.test.ts src/main/runtime/project-group-folder-workspace-selectors.test.ts src/renderer/src/hooks/ipc-events/worktree-event-runtime-activation.test.ts src/renderer/src/hooks/ipc-events/worktree-event-lineage-refresh.test.ts src/renderer/src/mobile-terminal-surface-recovery src/renderer/src/lib/worktree-agent-activation-gate.test.ts src/renderer/src/lib/worktree-agent-activation-seam.test.ts src/renderer/src/components/terminal-pane/pty-connection/reattach-success-settle.test.ts src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-newer-daemon-hold.test.ts src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-live-bookmark.test.ts" ] }, { diff --git "a/docs/issue/\346\211\213\346\234\272\346\211\223\345\274\200\345\267\245\344\275\234\345\214\272\347\273\210\347\253\257\350\272\253\344\273\275\346\201\242\345\244\215/journal.md" "b/docs/issue/\346\211\213\346\234\272\346\211\223\345\274\200\345\267\245\344\275\234\345\214\272\347\273\210\347\253\257\350\272\253\344\273\275\346\201\242\345\244\215/journal.md" index 89cfe3e8bdde..d32d180210f5 100644 --- "a/docs/issue/\346\211\213\346\234\272\346\211\223\345\274\200\345\267\245\344\275\234\345\214\272\347\273\210\347\253\257\350\272\253\344\273\275\346\201\242\345\244\215/journal.md" +++ "b/docs/issue/\346\211\213\346\234\272\346\211\223\345\274\200\345\267\245\344\275\234\345\214\272\347\273\210\347\253\257\350\272\253\344\273\275\346\201\242\345\244\215/journal.md" @@ -4,7 +4,7 @@ slug: '手机打开工作区终端身份恢复' status: testing created: 2026-10-24 updated: 2026-11-09 -external_ids: ['STA-3859', 'Fartown/orca#92'] +external_ids: ['STA-3859', 'Fartown/orca#92', 'Fartown/orca#95'] --- # 手机打开工作区终端身份恢复 开发 Journal @@ -259,3 +259,12 @@ external_ids: ['STA-3859', 'Fartown/orca#92'] - 完成内容补充:生产 Mac 先保存 `renderer-before.json` 备份,再仅移除其他两个工作区 mounted 列表中已由新鲜主机证明属于当前工作区的裸 `4/5` 缓存,使用现有 createTab 恢复原 tab/leaf。桌面4→6,主机发布6;`mac-host-after-repair.json` / `mac-renderer-after-repair.json` 对比确认六个 handle/incarnation 不变、两个根 PID/启动时间不变、导航不变、主进程仍为19549;没有退出应用、关闭 tab 或向生产终端输入。 - 未解决问题:永久代码尚未正式发布/安装,真实手机13标签的当前画面未获得;当前状态 testing,不是 done。完整 e2e 类型检查有 cloud/mobile 未装独立依赖的诊断,目标 spec 无诊断。初次旧场景 E2E 被上游 setup script 提示卡挡住,复用现有 dismiss helper 后重跑,不改产品提示卡。 - 下一步:收齐重跑与 PR 检查,发布防复发版本;正式安装重启须取得该 Mac 的明确许可,不能把现场状态修复当作永久代码已经安装。 + +### 2026-11-09 既有 pane 连接与恢复判定源码复核 + +- 本轮目标:复核此前三轮之后仍可增生空 shell 的完整调用链;先给出源代码反例,再修改准入。 +- 完成内容:确认 numeric sessionId + owner 缺失可从共享 fresh 进入 provider;ambiguous 被当 owned;partial placement 提前 adopted;window 因 live reattach 无回放加睡眠 note 清绑定重开;pending null/旧 generation 直接 fresh。分别拦住这些分支,正向退出和正常新建保留。 +- 代码或文档变更:新增 main feature-owned numeric admission,共享 spawn-operation 接线;调整原 adoption/gate/reattach 接缝及对应现有反例测试;登记 REQ-008、TC-023 和源码审查记录。无 wire 变更,无用户现场 tab 删除。 +- 验证证据:状态 testing。主类型检查、feature checks、架构/Fork/三项本地化通过。实际应用 7 项通过:真实 numeric fallback + 无 owner 请求拒绝、4 项 Git/folder 旧 census 路径、无答复 attach 后 reload、SSH Git/folder 手机激活及隔离客户端重启。最后补查 sleeping claim 后 partial placement 的拒绝,最终完整 feature checks 为 13 文件、224 项通过;最终主类型、按 PR base 的 changed-code quality、架构与 Fork 门禁均通过。原始记录在 `.docs/mobile-terminal-recovery-ui-validation/2026-11-09-source-audit/`。 +- 未解决问题:用户 Mac 正在运行的 .76 未包含这轮代码。先前 #91 的 Mac build 已 cancelled、publish skipped,不能宣称永久包已安装。现场 21:25 新建 PTY 16 的发起者尚无完整 breadcrumb,不能把源码反例直接当作该次运行的唯一根因。pending null 的拒绝覆盖当前连接尝试,不把任何未来 remount 或旧 host 的行为假定为已验证。额外 E2E 全树类型检查有 42 个 cloud/mobile 依赖或 stub 错误,与基线逐条相同,无新增;不把它记为通过。 +- 下一步:完成本轮源码复核后的针对性验证并提交独立 PR;正式产物和 Mac 安装/重启须分开记录,未获该主机重启授权不退出应用。 diff --git "a/docs/issue/\346\211\213\346\234\272\346\211\223\345\274\200\345\267\245\344\275\234\345\214\272\347\273\210\347\253\257\350\272\253\344\273\275\346\201\242\345\244\215/requirements/\346\211\213\346\234\272\346\211\223\345\274\200\345\267\245\344\275\234\345\214\272\347\273\210\347\253\257\350\272\253\344\273\275\346\201\242\345\244\215.md" "b/docs/issue/\346\211\213\346\234\272\346\211\223\345\274\200\345\267\245\344\275\234\345\214\272\347\273\210\347\253\257\350\272\253\344\273\275\346\201\242\345\244\215/requirements/\346\211\213\346\234\272\346\211\223\345\274\200\345\267\245\344\275\234\345\214\272\347\273\210\347\253\257\350\272\253\344\273\275\346\201\242\345\244\215.md" index 6adc24114521..1e5b8b4e5e3f 100644 --- "a/docs/issue/\346\211\213\346\234\272\346\211\223\345\274\200\345\267\245\344\275\234\345\214\272\347\273\210\347\253\257\350\272\253\344\273\275\346\201\242\345\244\215/requirements/\346\211\213\346\234\272\346\211\223\345\274\200\345\267\245\344\275\234\345\214\272\347\273\210\347\253\257\350\272\253\344\273\275\346\201\242\345\244\215.md" +++ "b/docs/issue/\346\211\213\346\234\272\346\211\223\345\274\200\345\267\245\344\275\234\345\214\272\347\273\210\347\253\257\350\272\253\344\273\275\346\201\242\345\244\215/requirements/\346\211\213\346\234\272\346\211\223\345\274\200\345\267\245\344\275\234\345\214\272\347\273\210\347\253\257\350\272\253\344\273\275\346\201\242\345\244\215.md" @@ -149,3 +149,10 @@ updated: 2026-11-05 | 2026-10-24 | 临时修复实施授权 | REQ-006 收窄到本次 adoption 兜底;普通重连异常列后续,保留桌面 seed 与后台 wake 的实际边界 | REQ-006 | D-005 | | 2026-10-25 | Claude 代码 review 的 daemon 反例 | 撤回“非目标全部 decline”,仅新增 recorded 原身份分支;daemon fallback 保留,numeric 残留如实记录 | REQ-006 | D-006 | | 2026-10-25 | 最终窄版代码复核与运行证据 | 按有效 recorded 单 leaf slice 记录本机/SSH 局部结果;独立 folder selector 跳过、多 leaf 与微竞态不升级为通过 | REQ-001 至 REQ-006 | D-007 | + +### REQ-008 恢复入口不能把未知结果升级为新建许可 + +- 当前状态:待实现。 +- 描述:已有数字 PTY 的 pane owner 缺失、重复归属、部分认领失败、pending 启动空结果以及 live attach 无回放均不得单独授权 fresh。 +- 本地验收:每个源码反例保留原 tab/leaf/进程,拒绝新增进程;host 正向确认原进程退出后正常恢复;正常用户新建 shell 仍可用。 +- SSH 验收:共享 host 入口按远端 PTY 标识和退出证明裁决;断联不发请求、不在客户端降级新建;Git/folder 工作区使用已有远端验证入口。 diff --git "a/docs/issue/\346\211\213\346\234\272\346\211\223\345\274\200\345\267\245\344\275\234\345\214\272\347\273\210\347\253\257\350\272\253\344\273\275\346\201\242\345\244\215/research/2026-11-09-\346\201\242\345\244\215\345\207\206\345\205\245\346\272\220\347\240\201\345\256\241\346\237\245.md" "b/docs/issue/\346\211\213\346\234\272\346\211\223\345\274\200\345\267\245\344\275\234\345\214\272\347\273\210\347\253\257\350\272\253\344\273\275\346\201\242\345\244\215/research/2026-11-09-\346\201\242\345\244\215\345\207\206\345\205\245\346\272\220\347\240\201\345\256\241\346\237\245.md" new file mode 100644 index 000000000000..02e0420abadd --- /dev/null +++ "b/docs/issue/\346\211\213\346\234\272\346\211\223\345\274\200\345\267\245\344\275\234\345\214\272\347\273\210\347\253\257\350\272\253\344\273\275\346\201\242\345\244\215/research/2026-11-09-\346\201\242\345\244\215\345\207\206\345\205\245\346\272\220\347\240\201\345\256\241\346\237\245.md" @@ -0,0 +1,17 @@ +# 2026-11-09 恢复准入源码审查 + +对应 Fartown/orca#95、REQ-001/002/003/004/006/008、TC-023。基线 a6445b0766,已有 #91 不覆盖既有 pane connect。 + +| 入口 | 基线代码能推出的反例 | 修订后的判断 | +| --- | --- | --- | +| deferred-session-reattach-connect → runPtySpawn → reconcilePaneOwner → spawnLocalPty | 普通数字 sessionId,owner 为 null,fresh 最后忽略数字 ID 并新建进程 | fresh 前必须有同一主机该 ID 的 absentOwner 或 stop 证明;无 owner 返回 unverified,provider 不被调用 | +| adoptLiveWorkspacePtySurfaces | 两个 surface 声明同 PTY,ambiguous 被计为 surfaced,跳过 host census | 仅 owned 可计为 surfaced,ambiguous declined,不自动覆盖或删除任何一方 | +| runWorktreeAgentActivationGate | 一项恢复成功,另一项 declined,却先返回 adopted;structured 也能提前返回 | 未安置 live 工作先 blocked,再判 adopted/structured | +| bindHandleReattachResult | isReattach true、空回放、客户端睡眠 done note,使 renderer 断开、清绑定并 cold resume | window 保留 live reattach;main-owned startPane 继续由主机仲裁 | +| runDeferredSessionReattachChoice | 前一请求 null 或 generation 不同,直接 fresh | 本次尝试失败,不新建也不把旧 generation 的 PTY 附着给新 generation | + +这是一组可达的源码反例,不依赖测试数量来成立。反例不等于生产事件的完整调用来源;21:25 PTY 16 只有 provider-returned 的新进程证据,缺 launch origin,不作唯一归因。 + +执行主机拥有 PTY 和持久化绑定。本机 main 持有本机 proof;SSH 场景通过 getAppPtyId 对齐对应 connectionId 的 PTY 标识。配对 runtime 和旧 host 独立更新,本轮不更改 wire;失联仍为 unverifiable,不请求本机替代,不新增重连或轮询。测试隔离目录、2222 端口及 SSH fixture HOME 均位于验证机,不接触用户 Mac 的服务或 session。 + +主机缺 owner 后能等待并在归属恢复后重连,正常新建与 host-positive exit 都必须保留。空 viewport 仍可能涉及输出恢复;不能仅凭本轮 no-fresh 断言所有可见内容问题已解决。所有 REQ 的整体验收状态继续以 journal 为准。 diff --git "a/docs/issue/\346\211\213\346\234\272\346\211\223\345\274\200\345\267\245\344\275\234\345\214\272\347\273\210\347\253\257\350\272\253\344\273\275\346\201\242\345\244\215/solutions/\346\211\213\346\234\272\345\267\245\344\275\234\345\214\272\345\216\237\347\273\210\347\253\257\350\272\253\344\273\275\346\201\242\345\244\215\346\226\271\346\241\210.md" "b/docs/issue/\346\211\213\346\234\272\346\211\223\345\274\200\345\267\245\344\275\234\345\214\272\347\273\210\347\253\257\350\272\253\344\273\275\346\201\242\345\244\215/solutions/\346\211\213\346\234\272\345\267\245\344\275\234\345\214\272\345\216\237\347\273\210\347\253\257\350\272\253\344\273\275\346\201\242\345\244\215\346\226\271\346\241\210.md" index 252004e15bdd..8c3c2f908f9f 100644 --- "a/docs/issue/\346\211\213\346\234\272\346\211\223\345\274\200\345\267\245\344\275\234\345\214\272\347\273\210\347\253\257\350\272\253\344\273\275\346\201\242\345\244\215/solutions/\346\211\213\346\234\272\345\267\245\344\275\234\345\214\272\345\216\237\347\273\210\347\253\257\350\272\253\344\273\275\346\201\242\345\244\215\346\226\271\346\241\210.md" +++ "b/docs/issue/\346\211\213\346\234\272\346\211\223\345\274\200\345\267\245\344\275\234\345\214\272\347\273\210\347\253\257\350\272\253\344\273\275\346\201\242\345\244\215/solutions/\346\211\213\346\234\272\345\267\245\344\275\234\345\214\272\345\216\237\347\273\210\347\253\257\350\272\253\344\273\275\346\201\242\345\244\215\346\226\271\346\241\210.md" @@ -1,3 +1,18 @@ +## 2026-11-09 源码复核后的恢复准入 + +本轮沿 renderer pane → IPC spawn → stable owner → provider 审查,确认 #91 只修复认领 fallback。补充以下约束: + +- 数字 sessionId 的既有 pane 连接,owner 缺失时拒绝进入 fresh;只允许同一执行主机给出的 `absentOwner` 或明确停止后的 replacement 放行。检查位于所有 IPC/runtime fresh 的共同入口,不新增 wire 字段。 +- 多个 surface 同时声明一个 PTY 是 `unverifiable`,不能当作已恢复。任一 live PTY declined,整体不得先返回 adopted/structured。 +- 客户端睡眠记录和没有回放的 attach 结果不能证明进程退出。window-owned 路径保留 live reattach,不先清绑定再启动新 shell。main-owned 显式 startPane 仍由 host 仲裁。 +- pending start 返回 null 时不立即再启动;结束本次附着为失败,等待既有连接/归属恢复入口或明确用户动作。旧 generation 的 pending start 也先等待结果,不因 generation 不同并行启动。 + +原 tab/leaf 的新建与关闭仍走既有 Store;本轮不根据名字或空画面删除终端。numeric ID 复用不能绕过 provider 已有的拒绝策略。 + +本机文件、进程、provider 和绑定由本机 main 拥有;SSH 文件、进程和服务由 SSH 执行主机拥有。共享 spawn guard 按 `getAppPtyId(connectionId, sessionId)` 比对主机自己的退出证明。SSH 失联继续交给连接层;本功能不重连、不定时探测,不把失联作为 `exited`。配对 runtime wire 不变,旧 peer 行为仍须单独标注验证边界。 + +以下历史设计保留当时决策,不能据其窄版 approved 状态声称本轮完整发布或安装成功。 + --- title: 手机工作区原终端身份恢复方案 type: solution diff --git "a/docs/issue/\346\211\213\346\234\272\346\211\223\345\274\200\345\267\245\344\275\234\345\214\272\347\273\210\347\253\257\350\272\253\344\273\275\346\201\242\345\244\215/tests/cases/\346\211\213\346\234\272\347\273\210\347\253\257\345\216\237\350\272\253\344\273\275\346\201\242\345\244\215.md" "b/docs/issue/\346\211\213\346\234\272\346\211\223\345\274\200\345\267\245\344\275\234\345\214\272\347\273\210\347\253\257\350\272\253\344\273\275\346\201\242\345\244\215/tests/cases/\346\211\213\346\234\272\347\273\210\347\253\257\345\216\237\350\272\253\344\273\275\346\201\242\345\244\215.md" index 70b3e172fdf0..b4623f34b833 100644 --- "a/docs/issue/\346\211\213\346\234\272\346\211\223\345\274\200\345\267\245\344\275\234\345\214\272\347\273\210\347\253\257\350\272\253\344\273\275\346\201\242\345\244\215/tests/cases/\346\211\213\346\234\272\347\273\210\347\253\257\345\216\237\350\272\253\344\273\275\346\201\242\345\244\215.md" +++ "b/docs/issue/\346\211\213\346\234\272\346\211\223\345\274\200\345\267\245\344\275\234\345\214\272\347\273\210\347\253\257\350\272\253\344\273\275\346\201\242\345\244\215/tests/cases/\346\211\213\346\234\272\347\273\210\347\253\257\345\216\237\350\272\253\344\273\275\346\201\242\345\244\215.md" @@ -361,3 +361,12 @@ flowchart LR - 自动入口:`numeric-terminal-surface-recovery.test.ts`;真实应用 `mobile-terminal-surface-recovery.spec.ts` 的 numeric fallback provider 用例及 SSH 用例。 - 证据:本次数字回归修改前4项失败、修改后通过;真实 local fallback provider 产生数字 PTY 后,经手机激活恢复原 tab/leaf、相同 incarnation/root PID 和 shell 中的随机变量,再次执行可读到原变量。图像与身份快照位于 `.docs/mobile-terminal-recovery-ui-validation/2026-11-09/evidence/numeric-app/`;SSH 验证位于同目录 `ssh-app/`。 - 边界:SSH 真机回归覆盖现行 managed server 路由与旧 host 混合版本,不宣称远端 relay 数字编号可被本地 provider 同样处理;裸编号的跨工作区冲突策略由权威清单回归覆盖。 + +## TC-023 源码反例:既有 pane 准入与不确定结果 + +- 关联:REQ-001、REQ-002、REQ-003、REQ-004、REQ-006、REQ-008。 +- 前置:数字 provider,仍 live 原 PTY;旧 pane/窗口和主机状态可能暂不同步。 +- 步骤与预期:缺 stable owner 的数字 restore 在 provider 调用前拒绝;补回 owner 后接回原进程;typed SessionNotFound 后可 fresh;普通不带 sessionId 的新建仍成功。两 surface 同时声明该 PTY 与一成功一 declined 时均 blocked,不 mint、不自动删除。live isReattach 为空回放且有 hibernation note 时不 disconnect/clear/fresh。pending null 或旧 generation 不直接启动。 +- 本机证据:实际 numeric fallback E2E 不再用虚拟 900001 作为首次启动 ID,直接创建真实数字 PTY;额外无 owner 数字请求被拒绝,三次手机激活仍保留 tab/leaf/PTY/incarnation/root PID、shell nonce 与后续输出。 +- SSH:隔离 root sshd 和执行主机 Git/folder 现有路径复跑,检查远端身份、root PID、画面和后续命令;旧服务端只作为混合版本兼容证据,不冒称旧 server 已获得新的主机 guard。 +- 执行状态:testing;结果与未验证边界见本轮 journal 和 `.docs/mobile-terminal-recovery-ui-validation/2026-11-09-source-audit/`。 diff --git a/src/main/ipc/pty/pty-dead-owner-respawn.test.ts b/src/main/ipc/pty/pty-dead-owner-respawn.test.ts index 609f80b0805e..e38756baa233 100644 --- a/src/main/ipc/pty/pty-dead-owner-respawn.test.ts +++ b/src/main/ipc/pty/pty-dead-owner-respawn.test.ts @@ -6,6 +6,7 @@ import { makePaneKey } from '../../../shared/stable-pane-id' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch' import { registerPtyHandlers } from './register-handlers' import { setLocalPtyProvider } from './provider/registry' +import { clearProviderPtyState } from './provider/state-cleanup' import { isHiddenRendererPty } from './delivery/pty-hidden-delivery-gate' vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) @@ -55,10 +56,17 @@ vi.mock('../../codex/codex-state-db-backfill-recovery', () => describe('registerPtyHandlers', () => { const { handlers, mainWindow } = setupPtyIpcSuite() - it.each([false, true])( - 'reattaches a live removed folder or respawns an absent caller (%s)', - async (live) => { - const livePtyId = live ? 'pty-live-removed-folder' : 'pty-proven-absent-owner' + it.each([ + { live: false, numeric: false, hasOwner: true }, + { live: true, numeric: false, hasOwner: true }, + { live: false, numeric: true, hasOwner: true }, + { live: true, numeric: true, hasOwner: true }, + { live: true, numeric: true, hasOwner: false } + ])( + 'reattaches live owners and requires host absence before replacing ($live, $numeric, $hasOwner)', + async ({ live, numeric, hasOwner }) => { + const livePtyId = numeric ? '4' : live ? 'pty-live-removed-folder' : 'pty-proven-absent-owner' + clearProviderPtyState(livePtyId) const worktreeId = live ? 'folder:removed' : 'repo-1::/tmp/proven-absent-owner' const cwd = '/tmp/proven-absent-owner' const tabId = 'tab-proven-absent-owner' @@ -112,7 +120,7 @@ describe('registerPtyHandlers', () => { root: { type: 'leaf' as const, leafId }, activeLeafId: leafId, expandedLeafId: null, - ptyIdsByLeafId: { [leafId]: livePtyId } + ptyIdsByLeafId: hasOwner ? { [leafId]: livePtyId } : {} } }, terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-proven-absent-owner' } @@ -157,23 +165,32 @@ describe('registerPtyHandlers', () => { store as never ) - const mounted = await handlers.get('pty:spawn')!(null, { - cols: 80, - rows: 24, - cwd, - command: 'codex resume proven-absent-session', - sessionId: livePtyId, - initiallyHidden: true, - worktreeId, - tabId, - leafId, - env: { - ORCA_PANE_KEY: paneKey, - ORCA_TAB_ID: tabId, - ORCA_WORKTREE_ID: worktreeId - } - }) + const mount = () => + handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd, + command: 'codex resume proven-absent-session', + sessionId: livePtyId, + initiallyHidden: true, + worktreeId, + tabId, + leafId, + env: { + ORCA_PANE_KEY: paneKey, + ORCA_TAB_ID: tabId, + ORCA_WORKTREE_ID: worktreeId + } + }) + const mounting = mount() + if (!hasOwner) { + await expect(mounting).rejects.toThrow('terminal_pane_owner_unverified') + expect(providerSpawn).not.toHaveBeenCalled() + expect(runtime.onPtyExit).not.toHaveBeenCalled() + session.terminalLayoutsByTabId[tabId].ptyIdsByLeafId = { [leafId]: livePtyId } + } + const mounted = hasOwner ? await mounting : await mount() expect(mounted).toMatchObject({ id: livePtyId }) if (live) { expect(providerSpawn).toHaveBeenCalledTimes(1) diff --git a/src/main/ipc/pty/spawn-operation.ts b/src/main/ipc/pty/spawn-operation.ts index 1b6ec2dc4e11..5d652a5dc2f2 100644 --- a/src/main/ipc/pty/spawn-operation.ts +++ b/src/main/ipc/pty/spawn-operation.ts @@ -1,4 +1,6 @@ import type { PaneSpawnOperation } from './pane/pane-spawn-operation' +import { assertNumericReattachMaySpawn } from '../../mobile-terminal-surface-recovery/numeric-reattach-admission' +import { getAppPtyId } from './provider/registry' import type { AdoptStablePaneArgs } from './ipc/spawn-types' import { adoptedOutcome, type PtySpawnOutcome } from './spawn-outcome' import type { PtySpawnOperationState, PtySpawnExecutionFacts } from './spawn-operation-state' @@ -205,6 +207,12 @@ export async function runPtySpawn( return inherit(adopted) } const fresh = async (operation: PaneSpawnOperation): Promise => { + assertNumericReattachMaySpawn({ + sessionId: args.sessionId, + requestedPtyId: args.sessionId ? getAppPtyId(args.connectionId, args.sessionId) : undefined, + absentOwnerPtyId: operation.absentOwner?.ptyId, + stoppedPtyId: ctx.replacedPaneOwner?.ptyId + }) if (prepared) { retirePreparation(adapter) } diff --git a/src/main/mobile-terminal-surface-recovery/numeric-reattach-admission.ts b/src/main/mobile-terminal-surface-recovery/numeric-reattach-admission.ts new file mode 100644 index 000000000000..1c0d9ef11159 --- /dev/null +++ b/src/main/mobile-terminal-surface-recovery/numeric-reattach-admission.ts @@ -0,0 +1,15 @@ +export function assertNumericReattachMaySpawn(args: { + sessionId?: string + requestedPtyId?: string + absentOwnerPtyId?: string + stoppedPtyId?: string +}): void { + if ( + args.sessionId && + /^\d+$/.test(args.sessionId.trim()) && + (!args.requestedPtyId || + (args.requestedPtyId !== args.absentOwnerPtyId && args.requestedPtyId !== args.stoppedPtyId)) + ) { + throw new Error('terminal_pane_owner_unverified') + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-resume-command.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-resume-command.spec.ts index 5dd547f6b7ac..0b5148e243cf 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-resume-command.spec.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-resume-command.spec.ts @@ -8,7 +8,6 @@ import { mainLayoutApplyTransport } from './pty-connection-test-main-pane-start-fixtures' import { flushAsyncTicks } from './pty-connection-test-async' -import { UUID_RE } from './pty-connection-test-constants' import { temporarilySetNavigatorUserAgent } from './pty-connection-test-dom' import { LEAF_2, @@ -61,8 +60,7 @@ export function registerPtyConnectionColdRestoreResumeCommandTests(): void { await restoreTerminalTestGlobals() }) - it('re-runs the resume command when a hibernated local session reattaches with no payload', async () => { - // Why: the daemon drops startup commands on reattach, so a passive hibernation record must replace a contentless adopted shell. + it('keeps a live local reattach when a hibernation note has no replay', async () => { const pendingTimeouts: (() => void)[] = [] const originalSetTimeout = globalThis.setTimeout globalThis.setTimeout = vi.fn((fn: () => void) => { @@ -146,24 +144,12 @@ export function registerPtyConnectionColdRestoreResumeCommandTests(): void { expect(deps.clearExitedPanePtyLayoutBinding).not.toHaveBeenCalled() return } - expect(transport.disconnect).toHaveBeenCalledTimes(1) - expect(transport.connect).toHaveBeenCalledTimes(2) - expect(transport.connect).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - command: "codex '-c' 'disable_paste_burst=true' 'resume' 'codex-session-1'", - env: expect.objectContaining({ - ORCA_PANE_KEY: paneKey, - ORCA_AGENT_LAUNCH_TOKEN: expect.stringMatching(new RegExp(`^${UUID_RE}$`)) - }) - }) - ) - expect(deps.clearExitedPanePtyLayoutBinding).toHaveBeenCalledWith(2, 'restored-session') - expect(deps.clearTabPtyId).toHaveBeenCalledWith('tab-1', 'restored-session') - expect(deps.syncPanePtyLayoutBinding).not.toHaveBeenCalledWith(2, 'restored-session') - expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(2, 'fresh-resume-pty') - expect(mockStoreState.clearSleepingAgentSession).toHaveBeenCalledWith(paneKey) - expect(window.api.pty.clearPendingPaneSerializer).toHaveBeenCalledWith(paneKey, 1) + expect(transport.disconnect).not.toHaveBeenCalled() + expect(transport.connect).toHaveBeenCalledTimes(1) + expect(deps.clearExitedPanePtyLayoutBinding).not.toHaveBeenCalled() + expect(deps.clearTabPtyId).not.toHaveBeenCalled() + expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(2, 'restored-session') + expect(transport.sendInput).not.toHaveBeenCalled() } finally { globalThis.setTimeout = originalSetTimeout } diff --git a/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-choice.ts b/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-choice.ts index 0d1bfc92f153..856ac24e6339 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-choice.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-choice.ts @@ -156,7 +156,8 @@ export function runDeferredSessionReattachChoice(session: ConnectPanePtySession) const pendingGeneration = pendingSpawnGenerationByPaneKey.get(session.pendingSpawnKey) if (pendingGeneration !== undefined && pendingGeneration !== session.tabGeneration) { recordPtyConnectDiagnostic(`pane=${session.pane.id} -> STALE PENDING SPAWN`) - session.startFreshSpawn() + session.reportError('terminal_pane_owner_unverified') + session.settlePaneAttachAttempt('failed') scheduleRuntimeGraphSync() return } @@ -170,17 +171,8 @@ export function runDeferredSessionReattachChoice(session: ConnectPanePtySession) return } if (!spawnedPtyId) { - // StrictMode remount must respawn after an empty completed spawn promise instead of attaching to a dead surface. - if (!isWebTerminalSurfaceTabId(session.deps.tabId)) { - console.warn( - `Pending PTY spawn for tab ${session.deps.tabId} resolved without a PTY id, retrying fresh spawn` - ) - } - if (sleptRemoteColdRestoreStartup || resumesFromSleepingNote) { - session.startFreshColdRestoreAgentResume(sleptRemoteColdRestoreStartup ?? undefined) - } else { - session.startFreshSpawn() - } + session.reportError('terminal_pane_owner_unverified') + session.settlePaneAttachAttempt('failed') return } session.clearPaneMode2031State() diff --git a/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-live-bookmark.test.ts b/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-live-bookmark.test.ts index 9749ffb21379..0d482447e8d4 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-live-bookmark.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-live-bookmark.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { ConnectPanePtySession } from './connect-pane-pty-session' import { runDeferredSessionReattachChoice } from './deferred-session-reattach-choice' import type * as PairedParkedTerminalRestore from './paired-parked-terminal-restore' +import { pendingSpawnByPaneKey, pendingSpawnGenerationByPaneKey } from './pty-connect-limits' // Live mirrored PTYs must reattach despite periodic crash-recovery bookmarks, which cannot authorize a second cold-restored agent. @@ -138,10 +139,37 @@ function expectAttachedToLiveRemotePty(session: ConnectPanePtySession): void { beforeEach(() => { vi.clearAllMocks() + pendingSpawnByPaneKey.clear() + pendingSpawnGenerationByPaneKey.clear() mocks.canRestorePairedParked = false }) describe('deferred reattach choice: mirrored remote pane with a live crash-recovery bookmark', () => { + it.each([null, 1])( + 'does not spawn over an unverifiable pending attempt, generation %s', + async (generation) => { + setStore(null) + mocks.state.tabsByWorktree = { [WORKTREE_ID]: [{ id: TAB_ID, ptyId: null }] } + const original = buildSession() + const session = buildSession(TAB_ID, { + deps: { ...original.deps, restoredPtyIdByLeafId: {} } + }) + pendingSpawnByPaneKey.set(session.pendingSpawnKey, Promise.resolve(null)) + if (generation !== null) { + pendingSpawnGenerationByPaneKey.set(session.pendingSpawnKey, generation) + } + + runDeferredSessionReattachChoice(session) + await Promise.resolve() + await Promise.resolve() + + expect(session.startFreshSpawn).not.toHaveBeenCalled() + expect(session.startFreshColdRestoreAgentResume).not.toHaveBeenCalled() + expect(session.transport.attach).not.toHaveBeenCalled() + expect(session.settlePaneAttachAttempt).toHaveBeenCalledWith('failed') + } + ) + it('retains a live binding when transport attachment throws', () => { setStore(null) const session = buildSession() diff --git a/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler.ts b/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler.ts index b15aa21917b0..202c898d5f7c 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler.ts @@ -167,37 +167,21 @@ export function bindHandleReattachResult(sessionBag: ConnectPanePtySession): voi const hasStructuralReplay = Boolean( connectResult?.snapshot || connectResult?.replay || connectResult?.coldRestore ) - // Why: reattach drops startup commands; only real hibernation is authority to retire an empty adopted shell and resume its provider session. + // Only main can adjudicate retiring a live empty shell; a window sleep note cannot prove exit. const mainStarts = mainStartsPane(session) if ( !hasStructuralReplay && connectResult?.isReattach && + mainStarts && // Why: main already judged this terminal after its start; asking again would loop. - !(mainStarts && session.mainStartAttach) && + !session.mainStartAttach && hasEmptyReattachRetireEvidence( ptyId, - connectResult.agentResumeRefused - ? null - : mainStarts - ? session.buildColdRestoreAgentResumeStartup() - : coldRestoreStartup + connectResult.agentResumeRefused ? null : session.buildColdRestoreAgentResumeStartup() ) ) { session.transport.disconnect() - if (mainStarts) { - // Main retires the empty shell and resumes the agent in its place (startPane). - session.startFreshColdRestoreAgentResume(null) - return false - } - if (staleSessionId) { - session.clearExitedPanePtyLayoutBinding(staleSessionId) - session.deps.clearTabPtyId(session.deps.tabId, staleSessionId) - } else { - session.syncPanePtyLayoutBinding(null) - } - session.startFreshColdRestoreAgentResume(coldRestoreStartup, { - forceBlankRestoredViewport: true - }) + session.startFreshColdRestoreAgentResume(null) return false } session.setPanePtyFitBinding(ptyId) diff --git a/src/renderer/src/lib/worktree-agent-activation-gate.test.ts b/src/renderer/src/lib/worktree-agent-activation-gate.test.ts index 75b0c7c667ba..47ca1f2ffae1 100644 --- a/src/renderer/src/lib/worktree-agent-activation-gate.test.ts +++ b/src/renderer/src/lib/worktree-agent-activation-gate.test.ts @@ -644,7 +644,7 @@ describe('worktree agent activation gate', () => { warn.mockRestore() }) - windowOwnedIt('reports adopted only for the PTYs that actually landed on a surface', async () => { + windowOwnedIt('blocks activation while any live PTY remains without a surface', async () => { const adoptedPtyId = `${WORKTREE_ID}@@orphan-agent` const unverifiablePtyId = `${WORKTREE_ID}@@ambiguous-agent` const { deps } = testDeps({ @@ -656,7 +656,7 @@ describe('worktree agent activation gate', () => { resumeCount: 0 }) - await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('adopted') + await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('blocked') }) it.each([ diff --git a/src/renderer/src/lib/worktree-agent-activation-gate.ts b/src/renderer/src/lib/worktree-agent-activation-gate.ts index df56b59a1b26..c0bbfbbe5900 100644 --- a/src/renderer/src/lib/worktree-agent-activation-gate.ts +++ b/src/renderer/src/lib/worktree-agent-activation-gate.ts @@ -234,6 +234,10 @@ export async function runWorktreeAgentActivationGate( }) } } + const placementPending = liveTerminalPlacementIsPending(liveWorkspaceSessions.length, placement) + if (placementPending && !workspaceHasSleepingAgentSessions(deps.getState(), worktreeId)) { + return 'blocked' + } if (liveSurfaceAdopted && !workspaceHasSleepingAgentSessions(deps.getState(), worktreeId)) { return 'adopted' } @@ -241,10 +245,6 @@ export async function runWorktreeAgentActivationGate( if (structured && !workspaceHasSleepingAgentSessions(deps.getState(), worktreeId)) { return 'structured' } - const placementPending = liveTerminalPlacementIsPending(liveWorkspaceSessions.length, placement) - if (placementPending && !workspaceHasSleepingAgentSessions(deps.getState(), worktreeId)) { - return 'blocked' - } const store = deps.getState() const claims = liveSleepingAgentClaims( store, @@ -269,12 +269,12 @@ export async function runWorktreeAgentActivationGate( // Only an inventory without unresolved live work may authorize initial seeding. return launched > 0 ? 'resumed' - : liveSurfaceAdopted - ? 'adopted' - : structured - ? 'structured' - : placementPending - ? 'blocked' + : placementPending + ? 'blocked' + : liveSurfaceAdopted + ? 'adopted' + : structured + ? 'structured' : 'empty' } diff --git a/src/renderer/src/lib/worktree-agent-live-surface-adoption.ts b/src/renderer/src/lib/worktree-agent-live-surface-adoption.ts index 6db5a6dcc6ce..e602f816ebf5 100644 --- a/src/renderer/src/lib/worktree-agent-live-surface-adoption.ts +++ b/src/renderer/src/lib/worktree-agent-live-surface-adoption.ts @@ -196,7 +196,7 @@ export async function adoptLiveWorkspacePtySurfaces( ): Promise<{ surfaced: boolean; declinedPtyIds: string[] }> { // Include persisted layout/tab.ptyId bindings; mounted-only ptyIdsByTabId misses dormant surfaces. const unbound = livePtyIds.filter( - (ptyId) => resolveTerminalTabPtyOwnership(getState(), worktreeId, ptyId).kind === 'none' + (ptyId) => resolveTerminalTabPtyOwnership(getState(), worktreeId, ptyId).kind !== 'owned' ) let surfaced = unbound.length < livePtyIds.length const declinedPtyIds: string[] = [] @@ -217,10 +217,15 @@ export async function adoptLiveWorkspacePtySurfaces( } for (const ptyId of [...unbound.filter((id) => !isUnowned(id)), ...unbound.filter(isUnowned)]) { // Recheck after RPC: panes can mount and bind while census is in flight. - if (resolveTerminalTabPtyOwnership(getState(), worktreeId, ptyId).kind !== 'none') { + const ownership = resolveTerminalTabPtyOwnership(getState(), worktreeId, ptyId) + if (ownership.kind === 'owned') { surfaced = true continue } + if (ownership.kind === 'ambiguous') { + declinedPtyIds.push(ptyId) + continue + } const owner = surfaceOwners?.get(ptyId) // Only execution-host proof permits minting; weaker ownership evidence forks live agents onto empty surfaces. if (!owner) { @@ -260,8 +265,13 @@ export async function adoptLiveWorkspacePtySurfaces( continue } // A recorded tab createTab had to mint under another id already holds this PTY. - if (resolveTerminalTabPtyOwnership(getState(), worktreeId, ptyId).kind !== 'none') { - surfaced = true + const reboundOwnership = resolveTerminalTabPtyOwnership(getState(), worktreeId, ptyId) + if (reboundOwnership.kind !== 'none') { + if (reboundOwnership.kind === 'owned') { + surfaced = true + } else { + declinedPtyIds.push(ptyId) + } continue } if (!canMintLiveTerminalFallback(ptyId)) { diff --git a/src/renderer/src/mobile-terminal-surface-recovery/numeric-terminal-surface-recovery.test.ts b/src/renderer/src/mobile-terminal-surface-recovery/numeric-terminal-surface-recovery.test.ts index 782a97dc5475..ed0279cc0435 100644 --- a/src/renderer/src/mobile-terminal-surface-recovery/numeric-terminal-surface-recovery.test.ts +++ b/src/renderer/src/mobile-terminal-surface-recovery/numeric-terminal-surface-recovery.test.ts @@ -4,6 +4,7 @@ import { runWorktreeAgentActivationGate } from '../lib/worktree-agent-activation import { LIVE_LEAF_ID, SIBLING_LEAF_ID, + sleepingRecord, seedExistingSurface, testDeps, WORKTREE_ID @@ -12,6 +13,80 @@ import { vi.mock('../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: false })) describe('numeric terminal surface recovery', () => { + it('does not report partial placement as adopted after skipping a live sleeping claim', async () => { + const { deps, resume } = testDeps({ + sessions: ['4', '5'].map((id) => ({ + id, + cwd: '/acp-workspace', + worktreeId: WORKTREE_ID, + title: 'Claude', + agentOwnership: id === '4' ? ('present' as const) : ('absent' as const) + })), + sleeping: [sleepingRecord('sleeping-pane', LIVE_LEAF_ID, 'saved-session')], + resumeCount: 0, + surfaceOwners: new Map([['5', { unowned: true, recorded: null }]]) + }) + seedExistingSurface(deps.getState(), { + tabId: 'sleeping-pane', + leafId: LIVE_LEAF_ID, + boundPtyId: '4' + }) + await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('blocked') + expect(resume).toHaveBeenCalledWith(WORKTREE_ID, { + skipClaimKeys: new Set([`${WORKTREE_ID}\0codex\0session_id\0saved-session`]) + }) + }) + + it('blocks when two panes claim the same live numeric PTY', async () => { + const { deps, createTab, resume } = testDeps({ + sessions: [ + { + id: '4', + cwd: '/acp-workspace', + worktreeId: WORKTREE_ID, + title: 'Claude', + agentOwnership: 'present' + } + ], + surfaceOwners: new Map([['4', { unowned: true, recorded: null }]]) + }) + for (const tabId of ['claim-a', 'claim-b']) { + seedExistingSurface(deps.getState(), { + tabId, + leafId: LIVE_LEAF_ID, + boundPtyId: '4' + }) + } + await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('blocked') + expect(createTab).not.toHaveBeenCalled() + expect(resume).not.toHaveBeenCalled() + }) + + it.each([false, true])( + 'blocks partial placement with a structured session: %s', + async (structured) => { + const { deps, createTab, resume } = testDeps({ + sessions: ['4', '5'].map((id) => ({ + id, + cwd: '/acp-workspace', + worktreeId: WORKTREE_ID, + title: 'Claude', + agentOwnership: 'present' as const + })), + structured, + surfaceOwners: new Map([['5', { unowned: true, recorded: null }]]) + }) + seedExistingSurface(deps.getState(), { + tabId: 'original-4', + leafId: LIVE_LEAF_ID, + boundPtyId: '4' + }) + await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('blocked') + expect(createTab).not.toHaveBeenCalled() + expect(resume).not.toHaveBeenCalled() + } + ) + it.each([WORKTREE_ID, 'folder:acp-workspace'])( 'restores the host-recorded numeric terminals despite stale bindings outside %s', async (worktreeId) => { diff --git a/tests/e2e/mobile-terminal-surface-recovery.spec.ts b/tests/e2e/mobile-terminal-surface-recovery.spec.ts index a2b0b0902eb7..91b754fc95df 100644 --- a/tests/e2e/mobile-terminal-surface-recovery.spec.ts +++ b/tests/e2e/mobile-terminal-surface-recovery.spec.ts @@ -417,7 +417,6 @@ test.describe('numeric fallback provider recovery', () => { worktreeId: workspace.id, tabId, leafId, - sessionId: '900001', initiallyHidden: true }) }, @@ -430,6 +429,35 @@ test.describe('numeric fallback provider recovery', () => { original.id ) expect(before?.rootProcessId).toBeGreaterThan(0) + const unverified = await orcaPage.evaluate( + async ({ workspace, ptyId, tabId, leafId }) => { + const store = window.__store! + store.getState().createTab(workspace.id, undefined, undefined, { + id: tabId, + initialLeafId: leafId, + activate: false, + recordInteraction: false + }) + try { + await window.api.pty.spawn({ + cols: 100, + rows: 30, + cwd: workspace.path, + worktreeId: workspace.id, + tabId, + leafId, + sessionId: ptyId + }) + return 'unexpected-success' + } catch (error) { + return error instanceof Error ? error.message : String(error) + } finally { + store.getState().closeTab(tabId) + } + }, + { workspace, ptyId: original.id, tabId: randomUUID(), leafId: randomUUID() } + ) + expect(unverified).toContain('terminal_pane_owner_unverified') const marker = `NUMERIC_RECOVERY_${randomUUID().replaceAll('-', '')}` await orcaPage.evaluate( ({ id, marker }) =>