diff --git a/CHANGELOG.md b/CHANGELOG.md index cc07f836..71edcd26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ All notable changes follow Semantic Versioning. ## Unreleased +- Added #24 neutral forwarding hooks, #25 attached-yield continuation on the same running handle, and #26 fixed-shape structural progress probes with detailed status limited to the exact owner. +- Added an observational, heartbeat-bounded session-snapshot progress fallback when structural conversation probes do not receive accepted online frames. It schema-validates through the ZCode client, scans only the durably accepted current turn, never reads raw logs or emits assistant prose/reasoning/tool output/file contents, and degrades to lifecycle-only updates without changing authoritative completion. - Stopped writing the Codex host's `hide_spawn_agent_metadata` flag; one setup now reconciles the managed Role and safely removes only a legacy `false` proved by complete numeric-v1 ownership evidence. - Added a packaged bilingual manual-uninstall guide for receipt-gated Role/config cleanup while retaining durable jobs, results, progress, logs, and history by default. - Fixed `$zcode:setup` managed Role reconciliation with Codex 0.147 effective configuration, which normalizes an otherwise exact Role registration with `nickname_candidates = null`. diff --git a/README.md b/README.md index a0c0bd4e..4ac658d7 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,9 @@ Foreground Rescue runs the constant forwarder in one native child thread. When t Use `/agent` or `/subagents` to select the Rescue child and inspect its transcript. `/ps` is different: it lists background terminals owned by the currently active thread, so switch to the child first if a long-running yielded child terminal still exists. A short command may finish before appearing there. The operating-system `ps` command can show processes and argv, but not Codex model activity or thread transcripts. The noninteractive qualification harness does not expose these TUI events, so it emits the machine-readable scoped observation `{ "observed": false, "code": "tui-evidence-not-exposed", "qualificationScope": "tui" }`. That observation is not a qualification result and does not claim that the UI passed or failed. -The child subscribes to online conversation progress when ZCode supports it. Allowlisted tool activity may include a control-free, one-line command or search-query preview shortened to 96 characters. Truncation is not secret redaction: a secret placed in a command or query can remain visible in the child transcript and durable status preview. Raw output, file contents, reasoning, assistant drafts, environment values, and authorization material are not progress fields. If the subscription or an optional progress sink fails, Rescue continues with lifecycle messages and the 20-second heartbeat; the revision-guarded terminal result remains authoritative. +The child subscribes to online conversation progress when ZCode supports it and structurally probes whether that subscription is actually delivering usable online frames. Allowlisted online tool activity may include a control-free, one-line command or search-query preview shortened to 96 characters. Truncation is not secret redaction: a secret placed in an online command or query can remain visible in the child transcript and durable status preview. + +If accepted online frames remain unavailable, Rescue can fall back to already schema-validated session snapshots at no more than heartbeat frequency. This fallback is bounded to the durably accepted current turn and emits only allowlisted tool state; it does not emit commands or queries. It never reads raw ZCode logs and never emits assistant prose or reasoning, arbitrary tool input or output, errors or metadata, raw paths, file or patch contents, identifiers, environment values, or authorization material. Progress observation is non-authoritative: failure degrades once to lifecycle-only updates and does not change job success. The separate revision-guarded session read after companion completion remains the authoritative terminal result. Background semantics remain unchanged: the child reserves the production background worker and returns the public job ID, while the one-time capability stays on production-owned protected descriptors. Use `$zcode:status`, `$zcode:result`, and `$zcode:cancel` for durable recovery. Ordinary steering, a wait timeout, or parent/child loss does not authorize a replacement execution. diff --git a/README.zh-CN.md b/README.zh-CN.md index 0a1c5f6d..04f97614 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -46,7 +46,9 @@ ZCode Desktop 与 ZCode CLI 分别保存 model provider 设置。运行 `$zcode: 使用 `/agent` 或 `/subagents` 选择 Rescue child 并查看它的 transcript。`/ps` 含义不同:它只列出当前活动线程拥有的后台 terminal,所以若一个耗时 child terminal 已 yield,应先切换到 child;短命令可能在出现在列表前就已结束。操作系统的 `ps` 只能显示进程和 argv,不能显示 Codex 模型活动或线程 transcript。非交互 qualification harness 不暴露这些 TUI event,因此会输出机器可读的作用域观测 `{ "observed": false, "code": "tui-evidence-not-exposed", "qualificationScope": "tui" }`。该观测不是资格结果,也不会声称 UI 已通过或失败。 -ZCode 支持时,child 会订阅 online conversation progress。allowlist 内的工具活动可以带一行、去控制字符、最长 96 字符的命令或搜索 query 预览。截断不是秘密脱敏:如果秘密本来就在命令或 query 中,它仍可能出现在 child transcript 和持久 status 预览里。原始输出、文件内容、推理、assistant draft、环境值和授权材料都不是进度字段。subscription 或可选进度 sink 失败时,Rescue 会降级到生命周期消息与 20 秒心跳;带 revision guard 的终态结果仍是权威结果。 +ZCode 支持时,child 会订阅 online conversation progress,并用结构化结果探测该 subscription 是否真的持续提供可用的 online frame。allowlist 内的 online 工具活动可以带一行、去控制字符、最长 96 字符的命令或搜索 query 预览。截断不是秘密脱敏:如果秘密本来就在 online 命令或 query 中,它仍可能出现在 child transcript 和持久 status 预览里。 + +若已接受的 online frame 始终不可用,Rescue 可以按不高于心跳的频率回退读取已经通过 schema 校验的 session snapshot。该回退严格限定在已持久确认的当前 turn,只输出 allowlist 内的工具状态,不输出命令或 query。它绝不读取原始 ZCode 日志,也不输出 assistant 正文或推理、任意工具输入/输出、错误或 metadata、原始路径、文件或 patch 内容、标识符、环境值或授权材料。进度观测不具权威性:失败只会一次性降级为 lifecycle-only 更新,不改变 job 的成功结果。companion 完成后的独立、带 revision guard 的 session read 仍是权威终态结果。 后台语义保持不变:child 只负责预留生产 background worker 并返回公开 job ID,一次性 capability 仍只经 production-owned protected descriptor 传输。持久恢复继续使用 `$zcode:status`、`$zcode:result` 和 `$zcode:cancel`。普通 steering、等待超时或父/child 丢失都不授权替代执行。 diff --git a/agents/zcode-rescue.toml.template b/agents/zcode-rescue.toml.template index bf9ffea1..80a46b6f 100644 --- a/agents/zcode-rescue.toml.template +++ b/agents/zcode-rescue.toml.template @@ -1,5 +1,7 @@ developer_instructions = """ -You are the installed ZCode Rescue forwarder. Accept only the exact initial assignment or one of the two exact continuation assignments documented below. Run exactly one mapped constant command in the current workspace, preserve stderr, and return public stdout verbatim and byte-for-byte. Never inspect or modify code independently, interpret results, retry, poll, cancel, choose a pending branch, or request/print/persist authorization material. Do not accept task text, command arguments, job or session identity, workspace identity, permissions, credentials, or authorization values in the assignment. Reject every other assignment without running a command. +You are the installed ZCode Rescue forwarder. Accept only the exact initial assignment or one of the two exact continuation assignments documented below. Run exactly one mapped constant command in the current workspace, preserve stderr, and return public stdout verbatim and byte-for-byte. Here exactly one command means exactly one `exec_command` companion process; continuation calls only observe its original running handle. Never inspect or modify code independently, interpret results, retry, start a second `exec_command`, cancel, choose a pending branch, or request/print/persist authorization material. Do not accept task text, command arguments, job or session identity, workspace identity, permissions, credentials, or authorization values in the assignment. Reject every other assignment without running a command. + +A companion result containing an exit code is terminal. A result containing a running execution or session handle is nonterminal: poll only that same handle with the host continuation tool until it reports an exit code. Partial stdout, stderr, heartbeat text, or an outer code-cell completion is not terminal and must not be returned as final output. A needs-choice response with exit code 3 is terminal for the current child turn. For the exact initial assignment "Run the installed ZCode Rescue forwarder now. Return its public stdout verbatim.", run only: node "{{PLUGIN_ROOT}}/scripts/zcode-companion.mjs" invoke rescue diff --git a/docs/superpowers/plans/2026-08-15-rescue-forwarder-progress-compatibility.md b/docs/superpowers/plans/2026-08-15-rescue-forwarder-progress-compatibility.md new file mode 100644 index 00000000..4e708fcd --- /dev/null +++ b/docs/superpowers/plans/2026-08-15-rescue-forwarder-progress-compatibility.md @@ -0,0 +1,434 @@ +# Rescue Forwarder and Progress Compatibility Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make native Rescue child hooks succeed, keep the child attached to one yielded companion execution until exit, and retain safe semantic progress when ZCode conversation frames are silent or incompatible. + +**Architecture:** Preserve the existing authoritative send/completion/result path and add compatibility only at observational boundaries. Hook classification short-circuits forwarding-child prompts; forwarder instructions and qualification distinguish a running host handle from a terminal exit; a fixed-result conversation probe drives a bounded reporter state machine; and a focused snapshot describer consumes only schema-validated `session/read` data for the accepted current turn. + +**Tech Stack:** Node.js 22 ESM, `node:test`, strict JSON/protocol validation, Codex 0.147 native subagents, ZCode broker/client protocol, GitHub Actions. + +--- + +### Task 1: Accept forwarding-child prompt hooks neutrally (#24) + +**Files:** +- Modify: `hooks/lib/hook-input.mjs` +- Modify: `hooks/user-prompt-hook.mjs` +- Modify: `tests/hooks.test.mjs` +- Regenerate: `marketplace/plugins/zcode/hooks/lib/hook-input.mjs` +- Regenerate: `marketplace/plugins/zcode/hooks/user-prompt-hook.mjs` + +- [ ] **Step 1: Add failing Codex 0.147 child-prompt tests** + +Add a tracer test in `tests/hooks.test.mjs` which starts an owned parent session, records a parent caller turn, seeds an unread completed job, then submits this bounded child shape: + +```js +{ + session_id: 'parent', turn_id: 'child-turn', cwd, + hook_event_name: 'UserPromptSubmit', transcript_path: null, + model: 'gpt', permission_mode: 'bypassPermissions', prompt: 'forward', + agent_id: 'rescue-child', agent_type: 'zcode-rescue', +} +``` + +Assert exit code `0`, exact JSON `{}`, unchanged parent active-turn identity and permission snapshot, no child caller turn or gate baseline, and no unread-job additional context. Add a table for `agent_id`-only, `agent_type`-only, empty/control-bearing/513-byte identifiers, and an extra unknown field; each must exit nonzero with no stdout. + +- [ ] **Step 2: Run the hook test and verify RED** + +Run: `node --test --test-name-pattern='forwarding-child prompt' tests/hooks.test.mjs` + +Expected: the valid child shape fails with `HOOK_INPUT_INVALID`; malformed shapes remain rejected. + +- [ ] **Step 3: Extend the exact schema and short-circuit child prompts** + +In `EVENTS.UserPromptSubmit`, allow optional `agent_id` and `agent_type`. After general identifier validation, reject half-present identity: + +```js +if (actualEvent === 'UserPromptSubmit' + && Object.hasOwn(input, 'agent_id') !== Object.hasOwn(input, 'agent_type')) throw inputError(); +``` + +In `hooks/user-prompt-hook.mjs`, immediately after `readHookInput()` and before resolving plugin data or calling `isOwnedSession`, return neutral output: + +```js +if (input.agent_id !== undefined) { + process.stdout.write('{}'); + process.exit(0); +} +``` + +Do not bind an executor here; `SubagentStart` remains the only binding event. + +- [ ] **Step 4: Run focused and hook suites and verify GREEN** + +Run: + +```bash +node --test --test-name-pattern='forwarding-child prompt|subagent hook marks|trusted SubagentStart' tests/hooks.test.mjs +node --test tests/hooks.test.mjs +``` + +Expected: all selected and full hook tests pass with no warning or secret-bearing output. + +- [ ] **Step 5: Regenerate packaged mirrors and verify identity** + +Run: `node scripts/build-marketplace-snapshot.mjs` + +Then run: `node --test tests/integration/marketplace-snapshot-build.mjs tests/integration/package-install.test.mjs` + +Expected: generated mirrors are byte-compatible and both tests pass. + +- [ ] **Step 6: Commit the tracer bullet** + +```bash +git add hooks tests/hooks.test.mjs marketplace/plugins/zcode/hooks +git commit -m "fix: accept forwarding child prompt hooks" +``` + +### Task 2: Keep Rescue attached to the yielded execution (#25) + +**Files:** +- Modify: `agents/zcode-rescue.toml.template` +- Modify: `skills/rescue/SKILL.md` +- Modify: `tests/skills-contracts.test.mjs` +- Modify: `tests/integration/skills.test.mjs` +- Modify: `tests/helpers/codex-rescue-qualification.mjs` +- Modify: `tests/codex-rescue-qualification.test.mjs` +- Modify: `tests/e2e/codex-skills-e2e.test.mjs` +- Modify as required for the long fixture: `tests/fixtures/fake-zcode-cli.mjs` +- Regenerate: `marketplace/plugins/zcode/agents/zcode-rescue.toml.template` +- Regenerate: `marketplace/plugins/zcode/skills/rescue/SKILL.md` + +- [ ] **Step 1: Add failing instruction-contract tests** + +Require named and generic forwarders to state all of these exact semantics: + +```text +A companion result containing an exit code is terminal. A result containing a +running execution or session handle is nonterminal: poll only that same handle +with the host continuation tool until it reports an exit code. Partial stdout, +stderr, heartbeat text, or an outer code-cell completion is not terminal and +must not be returned as final output. A needs-choice response with exit code 3 +is terminal for the current child turn. +``` + +Assert the contract still forbids a second `exec_command`, retry, cancellation, branch choice, or independent code inspection. + +- [ ] **Step 2: Run instruction tests and verify RED** + +Run: `node --test --test-name-pattern='yielded|terminal companion|needs-choice' tests/skills-contracts.test.mjs tests/integration/skills.test.mjs` + +Expected: assertions fail because the current Role and generic instructions treat only the outer command call as a unit and do not define same-handle polling. + +- [ ] **Step 3: Update named and generic forwarder instructions** + +Add the same terminal contract to `agents/zcode-rescue.toml.template` and the generic block of `skills/rescue/SKILL.md`. Keep the three constant commands, fixed assignments, one-child routing, stdout/stderr behavior, and choice wording unchanged. Clarify that “exactly one command” means one `exec_command` companion process; continuation calls only observe its original running handle. + +- [ ] **Step 4: Add failing qualification fixtures for yielded execution** + +Extend the captured-event fixtures so a valid child timeline contains: + +```js +[ + { tool: 'exec_command', result: { output: '[zcode] started\n', session_id: 41 } }, + { tool: 'write_stdin', args: { session_id: 41, chars: '' }, result: { output: '[zcode] heartbeat\n', session_id: 41 } }, + { tool: 'write_stdin', args: { session_id: 41, chars: '' }, result: { output: 'public result\n', exit_code: 0 } }, +] +``` + +Add adversarial fixtures for a second `exec_command`, changed handle, continuation with nonempty input, missing terminal exit code, child final before terminal output, parent terminal before child, and polling after exit. Preserve existing `needs-choice` exit-code-3 and same-child continuation fixtures. + +- [ ] **Step 5: Run qualification tests and verify RED** + +Run: `node --test tests/codex-rescue-qualification.test.mjs` + +Expected: valid yielded named/generic routes fail under the current single-tool-call parser while existing one-shot cases still pass. + +- [ ] **Step 6: Teach qualification to validate one execution plus bounded same-handle polls** + +Refactor `parseCapturedExecEnvelope()` into strict host-tool envelope classification that recognizes the captured Codex 0.147 `tools.exec_command` and continuation (`tools.write_stdin`) shapes. Track: + +```js +{ + execCount: 1, + handle: validatedPositiveSafeInteger, + pollCount: boundedCount, + terminalExitCode: validatedInteger, + terminalEventIndex: validatedIndex, +} +``` + +Require the first running result to establish the handle, every continuation to use exactly that handle with empty observation input, and exactly one final result containing an exit code. Keep stdout binding and child/parent ordering checks; never inspect output contents other than the existing public sentinel/choice contract. + +- [ ] **Step 7: Verify qualification GREEN and regenerate mirrors** + +Run: + +```bash +node --test tests/codex-rescue-qualification.test.mjs tests/skills-contracts.test.mjs tests/integration/skills.test.mjs +node scripts/build-marketplace-snapshot.mjs +node --test tests/integration/marketplace-snapshot-build.mjs +``` + +Expected: valid one-shot and yielded routes pass; every changed-handle/second-command/early-final fixture fails closed. + +- [ ] **Step 8: Extend installed long-running qualification** + +Use the existing fake completion delay/gate and process marker so the installed foreground Rescue exceeds the host initial yield. Assert one companion invocation/session send, selected-child terminal ordering after the original handle’s exit code, and no live orphan process after child completion. Keep the opt-in environment and credential cleanup contracts unchanged. + +- [ ] **Step 9: Run installed qualification harness in deterministic mode** + +Run: `node --test tests/e2e/codex-skills-e2e.test.mjs` + +Expected: deterministic tests pass and authenticated-credit cases report the repository’s explicit opt-in skip unless the environment enables them. + +- [ ] **Step 10: Commit the tracer bullet** + +```bash +git add agents skills tests marketplace/plugins/zcode/agents marketplace/plugins/zcode/skills +git commit -m "fix: keep rescue attached through execution exit" +``` + +### Task 3: Probe conversation compatibility structurally (#26) + +**Files:** +- Modify: `scripts/lib/conversation-progress.mjs` +- Modify: `scripts/lib/progress.mjs` +- Modify: `scripts/lib/review.mjs` +- Modify: `scripts/lib/state.mjs` +- Modify: `tests/conversation-progress.test.mjs` +- Modify: `tests/progress.test.mjs` +- Modify: `tests/state.test.mjs` +- Modify: `tests/render-progress.test.mjs` +- Modify: `tests/job-control.test.mjs` +- Modify: `tests/integration/companion.test.mjs` +- Modify: `tests/fixtures/fake-zcode-cli.mjs` +- Regenerate corresponding files under: `marketplace/plugins/zcode/` + +- [ ] **Step 1: Add failing fixed-classification protocol tests** + +Change the wished-for describer API so `observe()` resolves a structural result: + +```js +{ + disposition: 'accepted', + deliveryKind: 'online', + events: [], +} +``` + +or: + +```js +{ + disposition: 'rejected', + reason: 'wire-version', + events: [], +} +``` + +Test fixed rejection reasons `wire-version`, `envelope-shape`, `sequence`, `topic`, `row-kind`, and `row-shape`. Verify `JSON.stringify(result)` never contains raw frame values, commands, paths, output, exception text, credentials, or identifiers copied from frame content. Initial frames may be structurally accepted but must report `deliveryKind: 'initial'`; recovery frames must not count as online. + +- [ ] **Step 2: Run conversation tests and verify RED** + +Run: `node --test tests/conversation-progress.test.mjs` + +Expected: current `[]`/`null` rejection behavior cannot satisfy fixed structural results. + +- [ ] **Step 3: Implement fixed structural validation results** + +Make validation return only normalized, allowlisted frame data or a fixed reason. Preserve exact schema checks, sequence/gap recovery, terminal fencing, row/tool caps, pending-observation caps, path containment, and public 256-byte messages. A valid online frame counts as accepted even if deduplication produces zero public events. + +- [ ] **Step 4: Add failing reporter state/probe tests** + +Exercise the wished-for reporter state: + +```js +{ + state: 'probing' | 'online' | 'snapshot-fallback' | 'lifecycle-only', + subscriptionAcknowledged: boolean, + framesReceived: number, + acceptedInitial: number, + acceptedOnline: number, + acceptedRecovery: number, + rejected: { 'wire-version': number, 'envelope-shape': number, sequence: number, topic: number, 'row-kind': number, 'row-shape': number }, + snapshotFallbackActive: boolean, + snapshotFallbackUnavailable: boolean, +} +``` + +Counters must saturate at a fixed safe integer bound, fixed keys must be exact, generic `state.updated` events must not set `online`, a valid zero-event online frame must set `online`, and diagnostics/state changes after terminal must be ignored. + +- [ ] **Step 5: Run reporter/state tests and verify RED** + +Run: `node --test tests/progress.test.mjs tests/state.test.mjs tests/render-progress.test.mjs` + +Expected: no compatibility state or owner-scoped probe persistence exists yet. + +- [ ] **Step 6: Implement the bounded reporter probe and durable owner facts** + +Add an observational callback from the conversation describer into `createProgressReporter()`. Store only exact enums, booleans, saturated counters, and fixed rejection keys. Add a state-store method dedicated to atomically updating `progressProbe` on the exact running job; queued or terminal jobs are no-ops. Exact owner status JSON may include `progressProbe`; text rendering and foreign/all-job views must not expose it. Probe persistence failure must be swallowed like progress preview failure. + +- [ ] **Step 7: Wire subscription acknowledgement and compatibility state** + +In `executeJob()`, mark acknowledgement only after `subscribeConversation()` validates its result. Keep subscribe failure observational. Ensure all probe hooks are stopped/fenced inside existing optional progress cleanup and cannot affect `primaryError`, completion, result extraction, cancellation, or exit status. + +- [ ] **Step 8: Add fake-protocol and companion integration coverage** + +Extend the fake ZCode fixture with deterministic modes for no frames, accepted initial-only frames, accepted online zero-event frames, and bounded malformed bursts. Assert owner status contains only fixed probe facts, public stderr contains only fixed messages, and embedded raw secrets never appear in stdout/stderr/job JSON. + +- [ ] **Step 9: Verify the complete structural probe** + +Run: + +```bash +node --test tests/conversation-progress.test.mjs tests/progress.test.mjs tests/state.test.mjs tests/render-progress.test.mjs tests/job-control.test.mjs tests/integration/companion.test.mjs +node scripts/build-marketplace-snapshot.mjs +node --test tests/integration/marketplace-snapshot-build.mjs +``` + +Expected: all structural states and privacy assertions pass; existing result/cancellation/subscription cleanup tests remain green. + +- [ ] **Step 10: Commit the tracer bullet** + +```bash +git add scripts tests marketplace/plugins/zcode +git commit -m "feat: observe conversation progress compatibility" +``` + +### Task 4: Fall back to bounded current-turn session progress (#27) + +**Files:** +- Create: `scripts/lib/session-progress.mjs` +- Modify: `scripts/lib/conversation-progress.mjs` +- Modify: `scripts/lib/progress.mjs` +- Modify: `scripts/lib/review.mjs` +- Modify: `tests/session-progress.test.mjs` +- Modify: `tests/conversation-progress.test.mjs` +- Modify: `tests/progress.test.mjs` +- Modify: `tests/job-control.test.mjs` +- Modify: `tests/integration/companion.test.mjs` +- Modify: `tests/fixtures/fake-zcode-cli.mjs` +- Modify: `tests/helpers/codex-rescue-qualification.mjs` +- Modify: `tests/codex-rescue-qualification.test.mjs` +- Modify: `tests/e2e/codex-skills-e2e.test.mjs` +- Modify: `README.md` +- Modify: `README.zh-CN.md` +- Modify: `CHANGELOG.md` +- Regenerate corresponding files under: `marketplace/plugins/zcode/` + +- [ ] **Step 1: Add failing snapshot-describer privacy and boundary tests** + +Define the wished-for interface: + +```js +const describer = createSessionProgressDescriber({ + workspace, + turnBoundary: { inputId, stateRevision, beforeMessageIds: new Set(historyIds) }, +}); +const events = await describer.observe(validatedSnapshot, observedAt); +``` + +Test `runtime.stateRevision >= stateRevision`, exclusion of every historical ID, selection of only the direct accepted input root (or exactly one visible non-synthetic current user root), and only linked assistant `parts[type === 'tool']`. Reasoning, text, files, patches, arbitrary input/output/error/metadata, sibling roots, hidden assistant prose, and raw paths must never enter returned events or serialized state. Deduplicate by `callId`, cap at 256 identities, and emit terminal-only when a call first appears completed/error. + +- [ ] **Step 2: Run snapshot tests and verify RED** + +Run: `node --test tests/session-progress.test.mjs` + +Expected: module import fails because the bounded snapshot describer does not exist. + +- [ ] **Step 3: Implement the focused snapshot describer** + +Create `scripts/lib/session-progress.mjs`. Consume only snapshots already validated by `ZCodeClient.readSession()`. Reuse or extract the existing allowlisted tool-name/start/terminal formatter and contained-path resolver from `conversation-progress.mjs`; do not synthesize v4 frames. Track only bounded call state and safe formatter output. Treat malformed boundary relationships as no events, not as permission to scan more history. + +- [ ] **Step 4: Add failing heartbeat/fallback scheduling tests** + +Inject controlled interval ticks and deferred `readSnapshot` promises into `createProgressReporter()`. Prove: + +```text +no read before accepted boundary +first heartbeat with zero accepted online frames starts fallback +rejection threshold may start fallback before that boundary +at most one read per heartbeat and one read in flight +accepted online recovery stops new reads and discards a late read result +read/normalization failure emits the fixed lifecycle-only diagnostic once +terminal cleanup never waits unboundedly for a read +``` + +The first heartbeat decision must occur even when generic lifecycle activity suppresses the visible “Still waiting” line. + +- [ ] **Step 5: Run scheduling tests and verify RED** + +Run: `node --test --test-name-pattern='snapshot|fallback|online recovery|heartbeat boundary' tests/progress.test.mjs tests/job-control.test.mjs` + +Expected: reporter has no accepted-boundary snapshot reader or fallback state transitions. + +- [ ] **Step 6: Implement bounded fallback scheduling** + +Add an accepted-boundary activation method that receives `readSnapshot` and the snapshot describer only after send acknowledgement and durable boundary persistence. On heartbeat or rejection threshold, transition `probing -> snapshot-fallback`, diagnose exactly `ZCode conversation frames were unavailable; using bounded session progress.`, and start at most one read. On read/normalization failure transition to `lifecycle-only` and diagnose exactly `ZCode semantic progress is unavailable; lifecycle updates will continue.` On accepted online recovery transition to `online`, increment an epoch, prevent future reads, and discard any older in-flight result. Snapshot work remains outside the authoritative completion promise and existing 250ms cleanup fence. + +- [ ] **Step 7: Wire the exact accepted turn in `executeJob()`** + +After `client.send()` is accepted and the job persists `inputId`, `startRevision`, and `beforeMessageIds`, build the snapshot describer with that same boundary and pass `() => client.readSession(activeSessionId)` to the reporter. Keep the final `readSession()` after completion separate and authoritative; never reuse a progress snapshot for result extraction or terminal proof. + +- [ ] **Step 8: Add deterministic fake-protocol integration coverage** + +Teach the fake peer to return current-turn in-progress and terminal tool parts, delayed reads, read failure, malformed-frame bursts, and later online recovery. Assert lifecycle-only output is fixed, snapshot tool starts/terminals are deduplicated, online recovery stops further `session/read`, final result/exit status are unchanged, and raw assistant/tool input/output/reasoning/file data never appears. + +- [ ] **Step 9: Update qualification and installed E2E expectations** + +Qualification must accept either safe semantic tool progress or one exact degraded diagnostic, while independently proving the Rescue child remains attached through the original companion exit. The real installed long Rescue must fail if durable preview remains startup-only with neither semantic activity nor an explicit degraded diagnostic. + +- [ ] **Step 10: Document compatibility behavior** + +Update both READMEs and CHANGELOG to explain: progress is observational; the plugin structurally probes subscription health; fallback reads schema-validated snapshots at heartbeat-bounded frequency for the accepted current turn only; raw ZCode logs are never read; assistant prose/reasoning/tool output/file contents are never emitted; and degraded lifecycle-only reporting does not change task success. + +- [ ] **Step 11: Verify fallback, integration, packaging, and privacy** + +Run: + +```bash +node --test tests/session-progress.test.mjs tests/conversation-progress.test.mjs tests/progress.test.mjs tests/job-control.test.mjs tests/integration/companion.test.mjs tests/codex-rescue-qualification.test.mjs tests/e2e/codex-skills-e2e.test.mjs +node scripts/build-marketplace-snapshot.mjs +npm run check +``` + +Expected: all deterministic tests and checks pass; authenticated-credit cases use explicit opt-in skips unless enabled; no raw probe/snapshot content appears in public or durable progress. + +- [ ] **Step 12: Commit the tracer bullet** + +```bash +git add scripts tests README.md README.zh-CN.md CHANGELOG.md marketplace/plugins/zcode +git commit -m "feat: fall back to bounded session progress" +``` + +### Task 5: Whole-branch qualification and PR delivery + +**Files:** +- Review all changes since: `df62cca` +- Modify only files required by review or CI findings + +- [ ] **Step 1: Run the complete local gate from a clean tree** + +Run: + +```bash +git status --short +npm run check +git diff --check df62cca..HEAD +``` + +Expected: clean worktree, exit code 0 for all checks, no whitespace errors. Opt-in real ZCode/Codex checks may skip only with their explicit repository diagnostics. + +- [ ] **Step 2: Perform final independent spec and quality review** + +Review the complete diff against `docs/superpowers/specs/2026-08-15-rescue-forwarder-progress-compatibility-design.md` and issues #24–#27. Fix every Critical or Important finding and re-run the affected focused tests plus `npm run check`. + +- [ ] **Step 3: Push and open the requested PR** + +Push `fix/rescue-progress-compatibility` and create a PR whose body contains `Closes #24`, `Closes #25`, `Closes #26`, and `Closes #27`, plus the exact local verification commands and the explicit opt-in status of installed real qualification. + +- [ ] **Step 4: Monitor and repair CI until green** + +Use `gh pr checks --watch` and inspect failed job logs. Reproduce each failure locally, add or adjust a regression test first, apply the minimal fix, re-run the focused test and `npm run check`, commit, push, and repeat until every required PR check succeeds. diff --git a/docs/superpowers/specs/2026-08-15-rescue-forwarder-progress-compatibility-design.md b/docs/superpowers/specs/2026-08-15-rescue-forwarder-progress-compatibility-design.md new file mode 100644 index 00000000..5bb7a3fb --- /dev/null +++ b/docs/superpowers/specs/2026-08-15-rescue-forwarder-progress-compatibility-design.md @@ -0,0 +1,247 @@ +# Rescue Forwarder and Progress Compatibility Design + +Status: proposed for maintainer review + +This design amends the hook-input, forwarder-completion, and progress-source +portions of `2026-08-09-rescue-native-subagent-progress-design.md`. It explicitly +supersedes the earlier no-`session/read`-polling non-goal only for the bounded +compatibility fallback defined here. Existing ownership, result, cancellation, +permission, accepted-turn, and parent-nonrelay contracts remain unchanged. + +## Problem + +Real Codex 0.147 and ZCode 0.16.3 runs exposed three independent failures in +the Rescue path: + +1. Codex adds `agent_id` and `agent_type` to `UserPromptSubmit` input for a + subagent, while the plugin's exact hook-input schema rejects those fields. + Every Rescue child turn therefore reports a failed prompt hook. +2. A long companion command can yield an execution handle before the command + exits. The managed Rescue Role can mistake the yielded output for the final + result, finish the child turn, and leave the companion running after the + trusted child executor has stopped. +3. A successful `v4/conversation/subscribe` acknowledgement does not guarantee + usable online frames. In observed ZCode 0.16.3 runs, real tool calls occurred + while the durable preview remained at `ZCode started the delegated turn.` + ZCode's own diagnostics reported v4 hydration and telemetry normalization + failures, but those internal implementation failures are outside this + repository's control. + +The plugin must remain useful when the installed ZCode produces compatible +conversation frames, incompatible frames, or no frames. The fix must not +require changes to ZCode and must not expose raw protocol data, reasoning, tool +output, file contents, credentials, or authorization material. + +## Goals + +- Accept the documented Codex 0.147 subagent prompt-hook shape without minting + a parent caller capability from a forwarding child. +- Keep the Rescue child alive until its one companion command reaches a real + terminal result. +- Distinguish compatible frames, rejected frames, and silent subscriptions at + the plugin protocol boundary using bounded structural diagnostics. +- Preserve semantic progress through a bounded `session/read` snapshot fallback + when online frames are absent or unusable. +- Keep progress observational: compatibility failures must not change job + ownership, permissions, accepted-turn completion, cancellation, results, or + exit status. +- Qualify the behavior with real long-running native-subagent execution as well + as deterministic fake-protocol tests. + +## Non-goals + +- Do not modify, patch, or depend on private implementation changes in ZCode. +- Do not parse ZCode log files or model-I/O artifacts. +- Do not relay continuous child progress into the parent model context. +- Do not expose arbitrary assistant text, reasoning, raw tool input/output, + environment values, or raw protocol objects as progress. +- Do not turn progress compatibility into a prerequisite for successful Rescue + completion. +- Do not add retry, polling, cancellation, or result interpretation authority + to the parent Codex agent. + +## Chosen Approach + +Use a plugin-owned compatibility pipeline with three independently testable +boundaries: + +1. **Hook classification** recognizes subagent prompt input and returns neutral + output before caller identity creation. +2. **Forwarder completion** treats yielded execution as nonterminal and follows + only the original execution handle until the command exits. +3. **Progress compatibility** probes structural protocol facts, normalizes + compatible frames, and falls back to bounded session snapshots when semantic + frames are unavailable. + +This approach keeps all remediation inside the plugin. Parsing ZCode logs was +rejected because logs are not a stable protocol, may be disabled or relocated, +and can contain information that is not safe for progress output. Treating a +subscribe acknowledgement as proof of health was rejected because it is the +behavior that produced the silent failure. Heartbeats alone were rejected as +the final behavior because they prove liveness but do not explain real tool +activity. + +## Hook Classification + +`UserPromptSubmit` accepts optional `agent_id` and `agent_type` fields matching +the Codex 0.147 wire schema. The fields must either both be absent or both be +valid bounded identifiers. + +When both fields are present, the hook treats the input as a subagent prompt and +returns `{}` without calling caller-turn creation, workspace fingerprinting, or +unread-job notification. `SubagentStart` remains the only event that binds a +Rescue child to its exact parent turn and Role. A child prompt can therefore +neither replace the parent's active turn nor create a caller credential. + +Ordinary parent prompts retain the existing exact schema, session ownership +check, caller-turn creation, optional gate baseline, and unread-job context. +Unknown fields still fail closed. + +## Forwarder Completion Contract + +The managed Role still runs exactly one constant companion command for an +initial invocation or one approved same-child continuation. Its authority does +not expand. + +The Role instructions additionally define terminal completion: + +- a result with an exit code is terminal; +- a result containing a running execution/session handle is nonterminal; +- the child must poll only that same handle with the host's continuation tool; +- partial stdout, stderr, heartbeat text, or an outer code-cell completion is + never a terminal companion result; and +- the child returns only after the original command exits, preserving public + stdout verbatim under the existing Rescue contract. + +The generic compatibility forwarder receives the same completion contract. +Setup upgrades the managed Role digest through the existing ownership and drift +checks. No parent retry or second child spawn is introduced. + +## Progress Compatibility Pipeline + +### Structural probe + +The existing authenticated client observes notifications before semantic +description. A bounded probe records only counters and fixed classifications: + +- subscription acknowledged; +- conversation frame received; +- initial, online, or recovery delivery; +- accepted frame; +- rejected frame by a fixed reason code such as wire version, envelope shape, + sequence, topic, row kind, or row shape; and +- snapshot fallback active or unavailable. + +The probe never stores raw frames, identifiers from frame content, previews, +commands, paths, reasoning, tool output, or validation exception text. Counter +storage is bounded per job. Public diagnostics use fixed strings; detailed +counter state is available only through existing owner-scoped status JSON. + +The conversation describer changes from silent `null` rejection to an internal +fixed rejection result. Valid-frame behavior and all existing public bounds stay +unchanged. + +### Compatibility state + +Each foreground Rescue progress reporter has one of four observational states: + +- `probing`: subscription may still produce a usable online frame; +- `online`: at least one usable online frame has been accepted; +- `snapshot-fallback`: no usable semantic frame was available by the first + heartbeat boundary, or a bounded rejected-frame threshold was reached; or +- `lifecycle-only`: both semantic frames and snapshot fallback are unavailable. + +Initial snapshots do not switch the reporter to `online` because historical +activity must not suppress fallback for the current turn. Once an online frame +is accepted, snapshot polling stops. State changes do not affect the +authoritative completion wait. + +### Bounded session snapshot fallback + +At the first heartbeat boundary with no accepted online frame, the reporter +reads the same session through the existing authenticated client. While fallback +is active, it repeats at most once per heartbeat interval. Only one read may be +in flight, and a read that has not settled is not duplicated. + +Snapshot processing uses the existing schema-validated session response and the +accepted current-turn boundary. It ignores messages that existed before send +and data unrelated to the accepted input. A small state map deduplicates tool +call identities and emits the same allowlisted start/terminal descriptions used +by online frames. It never emits assistant prose, reasoning, tool output, file +contents, arbitrary input objects, or historical activity. + +If the supported session snapshot cannot represent a tool's in-progress state, +the fallback may emit a terminal observation when it first becomes visible; it +must not invent a start time or status. Snapshot state and deduplication retain +the existing row/tool count bounds. + +A snapshot read or normalization failure emits one fixed diagnostic, transitions +to `lifecycle-only`, and preserves the heartbeat and authoritative result wait. +There is no retry faster than the heartbeat interval. + +## User-visible Behavior + +The parent continues to show native child lifecycle and the terminal Rescue +result only. Detailed progress remains in the selected Rescue child and the +owner-scoped durable preview. + +The child can show one fixed compatibility diagnostic before normal progress: + +- `ZCode conversation frames were unavailable; using bounded session progress.` +- `ZCode semantic progress is unavailable; lifecycle updates will continue.` + +These messages describe capability, not task success or failure. Command and +query previews remain control-free and bounded under the existing policy. + +A `needs-choice` companion response remains intentionally terminal for the +current child turn and retains exit code 3 semantics. It is not confused with a +yielded running execution because it contains a complete public response and a +real exit code. + +## Failure and Cancellation Semantics + +- Hook classification fails closed on malformed or half-present child identity. +- A yielded command remains owned by the original child; child completion cannot + precede companion exit. +- Parent wait timeout or steering never creates a second child or command. +- SIGINT, SIGTERM, explicit cancellation, SessionEnd, and orphan recovery retain + their current acknowledged-stop and worker-lease behavior. +- Probe, frame description, snapshot reads, and progress sinks are + observational. Their failure cannot overwrite a terminal job or result. +- Terminal completion stops heartbeat, online observation, and snapshot polling, + then drains bounded progress work under the existing cleanup deadline. + +## Testing + +Implementation follows red-green-refactor and adds these tracer-level tests: + +1. A real Codex 0.147-shaped subagent `UserPromptSubmit` succeeds neutrally and + leaves the exact parent active turn unchanged; malformed identity fails. +2. A managed Role qualification fixture runs longer than the host's initial + execution yield, emits heartbeats, and proves that the child does not complete + until the same command exits. Initial and choice continuation use the same + child. +3. Protocol tests distinguish no frames, accepted frames, malformed frames, and + rejected-frame bursts without persisting raw payloads. +4. Snapshot fallback starts only after the first heartbeat without an accepted + online frame, emits bounded deduplicated tool progress, stops after online + recovery, and degrades safely when reads fail. +5. Existing result, cancellation, ownership, permission, heartbeat, durable + preview, subscription cleanup, and parent-nonrelay tests remain green. +6. Installed-plugin E2E runs a real long Rescue and verifies that the selected + child remains active until companion termination and shows either semantic + progress or one explicit degraded diagnostic rather than silent startup-only + state. + +## Delivery Boundaries + +The work is delivered as independent tracer bullets: + +1. subagent prompt-hook compatibility; +2. same-execution forwarder completion; +3. bounded structural progress probing; and +4. session snapshot fallback driven by probe state. + +The first three can land independently. Snapshot fallback depends on the probe +state and rejection classifications. Release documentation describes the +fallback and clarifies that ZCode log files are never consumed. diff --git a/hooks/lib/hook-input.mjs b/hooks/lib/hook-input.mjs index 652e0889..81e04d7c 100644 --- a/hooks/lib/hook-input.mjs +++ b/hooks/lib/hook-input.mjs @@ -10,7 +10,7 @@ const COMMON = ['session_id', 'transcript_path', 'cwd', 'hook_event_name']; const WITH_MODEL = [...COMMON, 'model']; const EVENTS = Object.freeze({ SessionStart: { fields: [...WITH_MODEL, 'permission_mode', 'source'], required: ['session_id', 'cwd', 'hook_event_name', 'model', 'permission_mode', 'source'] }, - UserPromptSubmit: { fields: [...WITH_MODEL, 'turn_id', 'permission_mode', 'prompt'], required: ['session_id', 'turn_id', 'cwd', 'hook_event_name', 'model', 'permission_mode', 'prompt'] }, + UserPromptSubmit: { fields: [...WITH_MODEL, 'turn_id', 'permission_mode', 'prompt', 'agent_id', 'agent_type'], required: ['session_id', 'turn_id', 'cwd', 'hook_event_name', 'model', 'permission_mode', 'prompt'] }, SubagentStart: { fields: [...WITH_MODEL, 'turn_id', 'permission_mode', 'agent_id', 'agent_type'], required: ['session_id', 'turn_id', 'cwd', 'hook_event_name', 'model', 'permission_mode', 'agent_id', 'agent_type'] }, SubagentStop: { fields: [...WITH_MODEL, 'turn_id', 'permission_mode', 'agent_id', 'agent_type', 'agent_transcript_path', 'stop_hook_active', 'last_assistant_message'], required: ['session_id', 'turn_id', 'cwd', 'hook_event_name', 'model', 'permission_mode', 'agent_id', 'agent_type', 'stop_hook_active'] }, Stop: { fields: [...WITH_MODEL, 'turn_id', 'permission_mode', 'stop_hook_active', 'last_assistant_message'], required: ['session_id', 'turn_id', 'cwd', 'hook_event_name', 'model', 'permission_mode', 'stop_hook_active'] }, @@ -31,6 +31,7 @@ export async function readHookInput(expectedEvent, options = {}) { for (const key of ['transcript_path', 'agent_transcript_path']) if (input[key] !== undefined && input[key] !== null && !boundedString(input[key], 4096)) throw inputError(); if (!boundedString(input.cwd, 4096) || !isAbsolute(input.cwd) || input.permission_mode !== undefined && !PERMISSIONS.has(input.permission_mode)) throw inputError(); if (input.stop_hook_active !== undefined && typeof input.stop_hook_active !== 'boolean') throw inputError(); + if (actualEvent === 'UserPromptSubmit' && Object.hasOwn(input, 'agent_id') !== Object.hasOwn(input, 'agent_type')) throw inputError(); if (actualEvent === 'SessionStart' && !['startup', 'resume', 'clear', 'compact'].includes(input.source)) throw inputError(); if (actualEvent === 'SessionEnd' && input.reason !== 'other') throw inputError(); input.cwd = await realpath(input.cwd).catch(() => { throw inputError(); }); diff --git a/hooks/user-prompt-hook.mjs b/hooks/user-prompt-hook.mjs index 23a3b290..63e10b20 100644 --- a/hooks/user-prompt-hook.mjs +++ b/hooks/user-prompt-hook.mjs @@ -9,7 +9,9 @@ import { fingerprintWorkspace, isOwnedSession, unreadJobs } from './lib/hook-sta import { readHookInput } from './lib/hook-input.mjs'; try { - const input = await readHookInput('UserPromptSubmit'); const dataRoot = resolvePluginDataRoot({ env: process.env, pluginRoot: resolve(fileURLToPath(new URL('../', import.meta.url))) }); + const input = await readHookInput('UserPromptSubmit'); + if (input.agent_id !== undefined) { process.stdout.write('{}'); process.exit(0); } + const dataRoot = resolvePluginDataRoot({ env: process.env, pluginRoot: resolve(fileURLToPath(new URL('../', import.meta.url))) }); if (!await isOwnedSession(dataRoot, input)) { process.stdout.write('{}'); process.exit(0); } const identity = createIdentityStore({ dataRoot }); await identity.beginCallerTurn({ sessionId: input.session_id, turnId: input.turn_id, workspace: input.cwd, permissionMode: input.permission_mode, prompt: input.prompt }); try { const fingerprint = await fingerprintWorkspace(input.cwd); await identity.recordGateBaseline({ sessionId: input.session_id, turnId: input.turn_id, workspace: input.cwd, fingerprint, permissionSnapshot: { permissionMode: input.permission_mode } }); } catch (error) { if (error?.code === 'GATE_BASELINE_EXISTS') { /* another exact hook invocation already recorded it */ } else { /* review gating is optional; caller authorization is not */ } } diff --git a/marketplace/plugins/zcode/CHANGELOG.md b/marketplace/plugins/zcode/CHANGELOG.md index 2b0874ba..71edcd26 100644 --- a/marketplace/plugins/zcode/CHANGELOG.md +++ b/marketplace/plugins/zcode/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes follow Semantic Versioning. ## Unreleased +- Added #24 neutral forwarding hooks, #25 attached-yield continuation on the same running handle, and #26 fixed-shape structural progress probes with detailed status limited to the exact owner. +- Added an observational, heartbeat-bounded session-snapshot progress fallback when structural conversation probes do not receive accepted online frames. It schema-validates through the ZCode client, scans only the durably accepted current turn, never reads raw logs or emits assistant prose/reasoning/tool output/file contents, and degrades to lifecycle-only updates without changing authoritative completion. +- Stopped writing the Codex host's `hide_spawn_agent_metadata` flag; one setup now reconciles the managed Role and safely removes only a legacy `false` proved by complete numeric-v1 ownership evidence. +- Added a packaged bilingual manual-uninstall guide for receipt-gated Role/config cleanup while retaining durable jobs, results, progress, logs, and history by default. - Fixed `$zcode:setup` managed Role reconciliation with Codex 0.147 effective configuration, which normalizes an otherwise exact Role registration with `nickname_candidates = null`. - Fixed local cachebuster reinstallations so SemVer build metadata keeps the installed plugin's marketplace-qualified data root valid. - Fixed installed `$zcode:*` skills failing with `DATA_ROOT_REQUIRED` when Codex does not inject `PLUGIN_DATA` into ordinary skill commands. diff --git a/marketplace/plugins/zcode/README.md b/marketplace/plugins/zcode/README.md index a1d214b8..4ac658d7 100644 --- a/marketplace/plugins/zcode/README.md +++ b/marketplace/plugins/zcode/README.md @@ -17,7 +17,7 @@ codex plugin marketplace add vitry/zcode-plugin-codex --ref marketplace codex plugin add zcode@vitry ``` -The release workflow builds `.agents/plugins/marketplace.json` plus `plugins/zcode/` with production dependencies on that branch. Restart Codex after installation, then run `$zcode:setup` in the target workspace. On the first run, setup may add the marketplace-qualified plugin data directory to Codex's writable roots; if it reports `restart-required`, restart Codex and rerun setup. Do not copy hooks out of the installed plugin cache. +The release workflow builds `.agents/plugins/marketplace.json` plus `plugins/zcode/` with production dependencies on that branch. Restart Codex after installation so the plugin itself is loaded, then run `$zcode:setup` in the target workspace. Setup normally reconciles the managed Role in that one run. On the first run, setup may instead add the marketplace-qualified plugin data directory to Codex's writable roots; this writable-root bootstrap is the only separate setup restart case, so if it reports `restart-required`, restart Codex and rerun `$zcode:setup`. Do not copy hooks out of the installed plugin cache. Discovery checks `ZCODE_PATH`, `zcode` on `PATH`, platform locations, and on macOS the bundled `/Applications/ZCode.app/Contents/Resources/glm/zcode.cjs`. Setup reports missing, outdated, unconfigured, unauthenticated, or untrusted installations; it does not download ZCode, configure a provider, or sign in for you. @@ -40,17 +40,19 @@ No public command provides unrestricted execution shortcuts. Review commands are ## Isolated Rescue role and inspection -`$zcode:setup` owns one digest-backed managed `zcode-rescue` Role under the stable plugin data root, not under a versioned plugin cache. It writes only the exact user-config registration and spawn-metadata leaf needed by that Role. A first install or owned upgrade returns `restart-required`; restart Codex and rerun `$zcode:setup` in a fresh session before using Rescue. Setup never adopts or overwrites a collision: a foreign `zcode-rescue` registration, a project Role with the same name, or a higher-precedence override fails closed with a setup diagnostic. The receipt, Role file, and effective registration must all match exactly. +`$zcode:setup` owns one digest-backed managed `zcode-rescue` Role under the stable plugin data root, not under a versioned plugin cache. It writes only the exact user-config Role registration and reconciles a fresh install or owned upgrade in one setup run. A proven numeric-v1 receipt is migrated in that same run. ZCode does not own `hide_spawn_agent_metadata`; the Codex host owns the collaboration tool schema, including whether `agent_type` is supplied. Setup only removes a legacy target-layer `false` when the numeric-v1 receipt, Role bytes, and exact registration prove that old ZCode setup wrote it. Setup never adopts or overwrites a collision: a foreign `zcode-rescue` registration, a project Role with the same name, or a higher-precedence override fails closed with a setup diagnostic. The receipt, Role file, and effective registration must all match exactly. Foreground Rescue runs the constant forwarder in one native child thread. When the host supports `agent_type`, Codex selects the named `zcode-rescue` Role. A generic child is a host-only compatibility fallback permitted only when the active spawn schema omits `agent_type` or proves that field unsupported before any child starts; missing, shadowed, drifted, or foreign Role state is never fallback-eligible. The parent runs only the read-only Role preflight, shows native lifecycle activity, and returns the child's final public stdout; it does not execute Rescue inline or copy child stderr, tool output, raw conversation frames, or intermediate progress into the parent thread. Use `/agent` or `/subagents` to select the Rescue child and inspect its transcript. `/ps` is different: it lists background terminals owned by the currently active thread, so switch to the child first if a long-running yielded child terminal still exists. A short command may finish before appearing there. The operating-system `ps` command can show processes and argv, but not Codex model activity or thread transcripts. The noninteractive qualification harness does not expose these TUI events, so it emits the machine-readable scoped observation `{ "observed": false, "code": "tui-evidence-not-exposed", "qualificationScope": "tui" }`. That observation is not a qualification result and does not claim that the UI passed or failed. -The child subscribes to online conversation progress when ZCode supports it. Allowlisted tool activity may include a control-free, one-line command or search-query preview shortened to 96 characters. Truncation is not secret redaction: a secret placed in a command or query can remain visible in the child transcript and durable status preview. Raw output, file contents, reasoning, assistant drafts, environment values, and authorization material are not progress fields. If the subscription or an optional progress sink fails, Rescue continues with lifecycle messages and the 20-second heartbeat; the revision-guarded terminal result remains authoritative. +The child subscribes to online conversation progress when ZCode supports it and structurally probes whether that subscription is actually delivering usable online frames. Allowlisted online tool activity may include a control-free, one-line command or search-query preview shortened to 96 characters. Truncation is not secret redaction: a secret placed in an online command or query can remain visible in the child transcript and durable status preview. + +If accepted online frames remain unavailable, Rescue can fall back to already schema-validated session snapshots at no more than heartbeat frequency. This fallback is bounded to the durably accepted current turn and emits only allowlisted tool state; it does not emit commands or queries. It never reads raw ZCode logs and never emits assistant prose or reasoning, arbitrary tool input or output, errors or metadata, raw paths, file or patch contents, identifiers, environment values, or authorization material. Progress observation is non-authoritative: failure degrades once to lifecycle-only updates and does not change job success. The separate revision-guarded session read after companion completion remains the authoritative terminal result. Background semantics remain unchanged: the child reserves the production background worker and returns the public job ID, while the one-time capability stays on production-owned protected descriptors. Use `$zcode:status`, `$zcode:result`, and `$zcode:cancel` for durable recovery. Ordinary steering, a wait timeout, or parent/child loss does not authorize a replacement execution. -Codex 0.147 is the only installed-host line pinned and targeted by this release's native Rescue qualification suite. A build is qualified only when the strict authenticated suite completes; a default machine-readable `unqualified` result is not compatibility evidence. No other Codex version is claimed compatible until its own installed qualification succeeds. Uninstalling the plugin does not automatically delete its stable private data, managed Role receipt/file, job history, or exact user-config leaves. Finish or cancel owned jobs first, then review that residual state and remove only entries proven to belong to this plugin; never delete a colliding user or project Role. +Codex 0.147 is the only installed-host line pinned and targeted by this release's native Rescue qualification suite. A build is qualified only when the strict authenticated suite completes; a default machine-readable `unqualified` result is not compatibility evidence. No other Codex version is claimed compatible until its own installed qualification succeeds. Uninstalling the plugin does not automatically delete its stable private data, managed Role receipt/file, job history, or exact user-config leaves. Finish or cancel owned jobs first, then follow the receipt-gated [manual uninstall and residual-state cleanup guide](docs/manual-uninstall.md); never delete a colliding user or project Role. ## Models diff --git a/marketplace/plugins/zcode/README.zh-CN.md b/marketplace/plugins/zcode/README.zh-CN.md index 94d62bb0..04f97614 100644 --- a/marketplace/plugins/zcode/README.zh-CN.md +++ b/marketplace/plugins/zcode/README.zh-CN.md @@ -17,7 +17,7 @@ codex plugin marketplace add vitry/zcode-plugin-codex --ref marketplace codex plugin add zcode@vitry ``` -发布 workflow 会在该分支生成 `.agents/plugins/marketplace.json` 和带生产依赖的 `plugins/zcode/`。安装后重启 Codex,再在目标工作区运行 `$zcode:setup`。首次运行可能把 marketplace 专属的数据目录加入 Codex writable roots;若返回 `restart-required`,重启 Codex 后再次运行 setup。不要把 hooks 从插件缓存复制到别处。 +发布 workflow 会在该分支生成 `.agents/plugins/marketplace.json` 和带生产依赖的 `plugins/zcode/`。安装后重启 Codex 以加载插件,再在目标工作区运行 `$zcode:setup`。Setup 通常在这一次运行中完成受管 Role 协调。首次运行也可能先把 marketplace 专属的数据目录加入 Codex writable roots;这个 writable-root bootstrap 是唯一独立的 setup 重启情形,若返回 `restart-required`,重启 Codex 后再次运行 `$zcode:setup`。不要把 hooks 从插件缓存复制到别处。 插件依次检查 `ZCODE_PATH`、`PATH` 中的 `zcode`、平台目录,以及 macOS 内置路径 `/Applications/ZCode.app/Contents/Resources/glm/zcode.cjs`。Setup 会报告缺失、版本过低、未配置、未认证或 hook 不可信,但不会下载 ZCode、配置 provider,也不会代替用户登录。 @@ -40,17 +40,19 @@ ZCode Desktop 与 ZCode CLI 分别保存 model provider 设置。运行 `$zcode: ## 隔离的 Rescue Role 与检查方式 -`$zcode:setup` 在稳定的 plugin data 根目录下管理一个带 digest 收据的 `zcode-rescue` Role,而不是把它写进带版本号的插件缓存。Setup 只写该 Role 所需的精确 user-config 注册项和 spawn-metadata 配置叶。首次安装或受管升级会返回 `restart-required`;请重启 Codex,并在新 session 中再次运行 `$zcode:setup` 后再使用 Rescue。Setup 不会接管或覆盖冲突:外部 `zcode-rescue` 注册、同名项目 Role、或更高优先级 override 都会 fail closed 并给出 setup 诊断。收据、Role 文件和有效注册必须精确一致。 +`$zcode:setup` 在稳定的 plugin data 根目录下管理一个带 digest 收据的 `zcode-rescue` Role,而不是把它写进带版本号的插件缓存。Setup 只写精确的 user-config Role 注册项,并在一次 setup 中完成首次安装或受管升级;具有完整所有权证据的 numeric-v1 收据也在同一次运行中迁移。ZCode 不拥有 `hide_spawn_agent_metadata`;Codex host 负责协作工具 schema,包括是否提供 `agent_type`。只有 numeric-v1 收据、Role 字节和精确注册能证明旧版 ZCode setup 写入了目标层 `false` 时,setup 才移除该旧配置叶。Setup 不会接管或覆盖冲突:外部 `zcode-rescue` 注册、同名项目 Role、或更高优先级 override 都会 fail closed 并给出 setup 诊断。收据、Role 文件和有效注册必须精确一致。 前台 Rescue 只在一个原生子线程中运行常量 forwarder。host 支持 `agent_type` 时,Codex 选择具名 `zcode-rescue` Role。generic child 只是 host-only 兼容回退:仅当当前 spawn schema 缺少 `agent_type`,或能证明该字段在任何 child 启动前已被拒绝时才允许;Role 缺失、被 shadow、漂移或属于外部配置时绝不回退。父线程只运行只读 Role preflight、显示原生生命周期并返回 child 的最终公开 stdout;它不会 inline 执行 Rescue,也不会把 child stderr、工具输出、原始 conversation frame 或中间进度复制到父线程。 使用 `/agent` 或 `/subagents` 选择 Rescue child 并查看它的 transcript。`/ps` 含义不同:它只列出当前活动线程拥有的后台 terminal,所以若一个耗时 child terminal 已 yield,应先切换到 child;短命令可能在出现在列表前就已结束。操作系统的 `ps` 只能显示进程和 argv,不能显示 Codex 模型活动或线程 transcript。非交互 qualification harness 不暴露这些 TUI event,因此会输出机器可读的作用域观测 `{ "observed": false, "code": "tui-evidence-not-exposed", "qualificationScope": "tui" }`。该观测不是资格结果,也不会声称 UI 已通过或失败。 -ZCode 支持时,child 会订阅 online conversation progress。allowlist 内的工具活动可以带一行、去控制字符、最长 96 字符的命令或搜索 query 预览。截断不是秘密脱敏:如果秘密本来就在命令或 query 中,它仍可能出现在 child transcript 和持久 status 预览里。原始输出、文件内容、推理、assistant draft、环境值和授权材料都不是进度字段。subscription 或可选进度 sink 失败时,Rescue 会降级到生命周期消息与 20 秒心跳;带 revision guard 的终态结果仍是权威结果。 +ZCode 支持时,child 会订阅 online conversation progress,并用结构化结果探测该 subscription 是否真的持续提供可用的 online frame。allowlist 内的 online 工具活动可以带一行、去控制字符、最长 96 字符的命令或搜索 query 预览。截断不是秘密脱敏:如果秘密本来就在 online 命令或 query 中,它仍可能出现在 child transcript 和持久 status 预览里。 + +若已接受的 online frame 始终不可用,Rescue 可以按不高于心跳的频率回退读取已经通过 schema 校验的 session snapshot。该回退严格限定在已持久确认的当前 turn,只输出 allowlist 内的工具状态,不输出命令或 query。它绝不读取原始 ZCode 日志,也不输出 assistant 正文或推理、任意工具输入/输出、错误或 metadata、原始路径、文件或 patch 内容、标识符、环境值或授权材料。进度观测不具权威性:失败只会一次性降级为 lifecycle-only 更新,不改变 job 的成功结果。companion 完成后的独立、带 revision guard 的 session read 仍是权威终态结果。 后台语义保持不变:child 只负责预留生产 background worker 并返回公开 job ID,一次性 capability 仍只经 production-owned protected descriptor 传输。持久恢复继续使用 `$zcode:status`、`$zcode:result` 和 `$zcode:cancel`。普通 steering、等待超时或父/child 丢失都不授权替代执行。 -Codex 0.147 是本次发布唯一被固定并纳入原生 Rescue installed-host qualification suite 的版本线。只有严格认证套件完整成功的 build 才算 qualified;默认的机器可读 `unqualified` 结果不是兼容性证据。其他 Codex 版本在各自的 installed qualification 成功前不宣称兼容。uninstall 插件不会自动删除稳定私有数据、受管 Role 收据/文件、job 历史或精确 user-config 配置叶。请先结束或取消 owner job,再审查这些卸载残留,只移除能证明属于本插件的条目;绝不能删除有冲突的用户或项目 Role。 +Codex 0.147 是本次发布唯一被固定并纳入原生 Rescue installed-host qualification suite 的版本线。只有严格认证套件完整成功的 build 才算 qualified;默认的机器可读 `unqualified` 结果不是兼容性证据。其他 Codex 版本在各自的 installed qualification 成功前不宣称兼容。uninstall 插件不会自动删除稳定私有数据、受管 Role 收据/文件、job 历史或精确 user-config 配置叶。请先结束或取消 owner job,再按[手动卸载与残留状态清理指南](docs/manual-uninstall.md)审查并移除能证明属于本插件的条目;绝不能删除有冲突的用户或项目 Role。 ## 模型 diff --git a/marketplace/plugins/zcode/agents/zcode-rescue.toml.template b/marketplace/plugins/zcode/agents/zcode-rescue.toml.template index bf9ffea1..80a46b6f 100644 --- a/marketplace/plugins/zcode/agents/zcode-rescue.toml.template +++ b/marketplace/plugins/zcode/agents/zcode-rescue.toml.template @@ -1,5 +1,7 @@ developer_instructions = """ -You are the installed ZCode Rescue forwarder. Accept only the exact initial assignment or one of the two exact continuation assignments documented below. Run exactly one mapped constant command in the current workspace, preserve stderr, and return public stdout verbatim and byte-for-byte. Never inspect or modify code independently, interpret results, retry, poll, cancel, choose a pending branch, or request/print/persist authorization material. Do not accept task text, command arguments, job or session identity, workspace identity, permissions, credentials, or authorization values in the assignment. Reject every other assignment without running a command. +You are the installed ZCode Rescue forwarder. Accept only the exact initial assignment or one of the two exact continuation assignments documented below. Run exactly one mapped constant command in the current workspace, preserve stderr, and return public stdout verbatim and byte-for-byte. Here exactly one command means exactly one `exec_command` companion process; continuation calls only observe its original running handle. Never inspect or modify code independently, interpret results, retry, start a second `exec_command`, cancel, choose a pending branch, or request/print/persist authorization material. Do not accept task text, command arguments, job or session identity, workspace identity, permissions, credentials, or authorization values in the assignment. Reject every other assignment without running a command. + +A companion result containing an exit code is terminal. A result containing a running execution or session handle is nonterminal: poll only that same handle with the host continuation tool until it reports an exit code. Partial stdout, stderr, heartbeat text, or an outer code-cell completion is not terminal and must not be returned as final output. A needs-choice response with exit code 3 is terminal for the current child turn. For the exact initial assignment "Run the installed ZCode Rescue forwarder now. Return its public stdout verbatim.", run only: node "{{PLUGIN_ROOT}}/scripts/zcode-companion.mjs" invoke rescue diff --git a/marketplace/plugins/zcode/hooks/lib/hook-input.mjs b/marketplace/plugins/zcode/hooks/lib/hook-input.mjs index 652e0889..81e04d7c 100644 --- a/marketplace/plugins/zcode/hooks/lib/hook-input.mjs +++ b/marketplace/plugins/zcode/hooks/lib/hook-input.mjs @@ -10,7 +10,7 @@ const COMMON = ['session_id', 'transcript_path', 'cwd', 'hook_event_name']; const WITH_MODEL = [...COMMON, 'model']; const EVENTS = Object.freeze({ SessionStart: { fields: [...WITH_MODEL, 'permission_mode', 'source'], required: ['session_id', 'cwd', 'hook_event_name', 'model', 'permission_mode', 'source'] }, - UserPromptSubmit: { fields: [...WITH_MODEL, 'turn_id', 'permission_mode', 'prompt'], required: ['session_id', 'turn_id', 'cwd', 'hook_event_name', 'model', 'permission_mode', 'prompt'] }, + UserPromptSubmit: { fields: [...WITH_MODEL, 'turn_id', 'permission_mode', 'prompt', 'agent_id', 'agent_type'], required: ['session_id', 'turn_id', 'cwd', 'hook_event_name', 'model', 'permission_mode', 'prompt'] }, SubagentStart: { fields: [...WITH_MODEL, 'turn_id', 'permission_mode', 'agent_id', 'agent_type'], required: ['session_id', 'turn_id', 'cwd', 'hook_event_name', 'model', 'permission_mode', 'agent_id', 'agent_type'] }, SubagentStop: { fields: [...WITH_MODEL, 'turn_id', 'permission_mode', 'agent_id', 'agent_type', 'agent_transcript_path', 'stop_hook_active', 'last_assistant_message'], required: ['session_id', 'turn_id', 'cwd', 'hook_event_name', 'model', 'permission_mode', 'agent_id', 'agent_type', 'stop_hook_active'] }, Stop: { fields: [...WITH_MODEL, 'turn_id', 'permission_mode', 'stop_hook_active', 'last_assistant_message'], required: ['session_id', 'turn_id', 'cwd', 'hook_event_name', 'model', 'permission_mode', 'stop_hook_active'] }, @@ -31,6 +31,7 @@ export async function readHookInput(expectedEvent, options = {}) { for (const key of ['transcript_path', 'agent_transcript_path']) if (input[key] !== undefined && input[key] !== null && !boundedString(input[key], 4096)) throw inputError(); if (!boundedString(input.cwd, 4096) || !isAbsolute(input.cwd) || input.permission_mode !== undefined && !PERMISSIONS.has(input.permission_mode)) throw inputError(); if (input.stop_hook_active !== undefined && typeof input.stop_hook_active !== 'boolean') throw inputError(); + if (actualEvent === 'UserPromptSubmit' && Object.hasOwn(input, 'agent_id') !== Object.hasOwn(input, 'agent_type')) throw inputError(); if (actualEvent === 'SessionStart' && !['startup', 'resume', 'clear', 'compact'].includes(input.source)) throw inputError(); if (actualEvent === 'SessionEnd' && input.reason !== 'other') throw inputError(); input.cwd = await realpath(input.cwd).catch(() => { throw inputError(); }); diff --git a/marketplace/plugins/zcode/hooks/user-prompt-hook.mjs b/marketplace/plugins/zcode/hooks/user-prompt-hook.mjs index 23a3b290..63e10b20 100644 --- a/marketplace/plugins/zcode/hooks/user-prompt-hook.mjs +++ b/marketplace/plugins/zcode/hooks/user-prompt-hook.mjs @@ -9,7 +9,9 @@ import { fingerprintWorkspace, isOwnedSession, unreadJobs } from './lib/hook-sta import { readHookInput } from './lib/hook-input.mjs'; try { - const input = await readHookInput('UserPromptSubmit'); const dataRoot = resolvePluginDataRoot({ env: process.env, pluginRoot: resolve(fileURLToPath(new URL('../', import.meta.url))) }); + const input = await readHookInput('UserPromptSubmit'); + if (input.agent_id !== undefined) { process.stdout.write('{}'); process.exit(0); } + const dataRoot = resolvePluginDataRoot({ env: process.env, pluginRoot: resolve(fileURLToPath(new URL('../', import.meta.url))) }); if (!await isOwnedSession(dataRoot, input)) { process.stdout.write('{}'); process.exit(0); } const identity = createIdentityStore({ dataRoot }); await identity.beginCallerTurn({ sessionId: input.session_id, turnId: input.turn_id, workspace: input.cwd, permissionMode: input.permission_mode, prompt: input.prompt }); try { const fingerprint = await fingerprintWorkspace(input.cwd); await identity.recordGateBaseline({ sessionId: input.session_id, turnId: input.turn_id, workspace: input.cwd, fingerprint, permissionSnapshot: { permissionMode: input.permission_mode } }); } catch (error) { if (error?.code === 'GATE_BASELINE_EXISTS') { /* another exact hook invocation already recorded it */ } else { /* review gating is optional; caller authorization is not */ } } diff --git a/marketplace/plugins/zcode/scripts/lib/conversation-progress.mjs b/marketplace/plugins/zcode/scripts/lib/conversation-progress.mjs index b473df54..8f0798e9 100644 --- a/marketplace/plugins/zcode/scripts/lib/conversation-progress.mjs +++ b/marketplace/plugins/zcode/scripts/lib/conversation-progress.mjs @@ -16,6 +16,14 @@ const MAX_TRACKED_ROWS = 256; const MAX_PENDING_OBSERVATIONS = 4; const PATH_RESOLUTION_TIMEOUT_MS = 100; const CONVERSATION_WIRE_VERSION = 3; +/** @typedef {{phase:string,message:string,observedAt:string}} PublicProgressEvent */ +/** @typedef {{disposition:'accepted',phase:'initial'|'online'|'recovery',events:PublicProgressEvent[]}|{disposition:'rejected'|'ignored',reason:string,events:PublicProgressEvent[]}} ObservationResult */ +/** @param {string} reason @returns {ObservationResult} */ +const rejected = (reason) => ({ disposition: 'rejected', reason, events: [] }); +/** @param {string} reason @returns {ObservationResult} */ +const ignored = (reason) => ({ disposition: 'ignored', reason, events: [] }); +/** @param {'initial'|'online'|'recovery'} phase @param {PublicProgressEvent[]} [events] @returns {ObservationResult} */ +const accepted = (phase, events = []) => ({ disposition: 'accepted', phase, events }); /** @param {unknown} value @param {number} [limit] */ export function normalizePreview(value, limit = PREVIEW_LIMIT) { @@ -31,7 +39,7 @@ export function normalizePreview(value, limit = PREVIEW_LIMIT) { /** * @param {{sessionId:string,subscriptionId:string,workspace:string}} options * @param {{resolvePath?:(value:unknown,workspaceRoot:string)=>Promise,pathTimeoutMs?:number}} [dependencies] - * @returns {Promise<{observe:(notification:unknown,observedAt:string)=>Promise>,markGap:()=>void,markTerminal:()=>void}>} + * @returns {Promise<{observe:(notification:unknown,observedAt:string)=>Promise,markGap:()=>void,markTerminal:()=>void}>} */ export async function createConversationProgressDescriber({ sessionId, subscriptionId, workspace }, dependencies = {}) { const workspaceRoot = await realpath(resolve(workspace)); @@ -41,7 +49,7 @@ export async function createConversationProgressDescriber({ sessionId, subscript const pathTimeoutMs = Number.isSafeInteger(requestedPathTimeout) && /** @type {number} */ (requestedPathTimeout) >= 1 ? /** @type {number} */ (requestedPathTimeout) : PATH_RESOLUTION_TIMEOUT_MS; const toolStates = new Map(); const rowStates = new Map(); - /** @type {Array<{notification:unknown,observedAt:string,resolve:(events:any[])=>void}>} */ + /** @type {Array<{notification:unknown,observedAt:string,resolve:(result:ObservationResult)=>void}>} */ const pending = []; let active = false; /** @type {number|undefined} */ @@ -50,12 +58,12 @@ export async function createConversationProgressDescriber({ sessionId, subscript let lastSeq; let terminal = false; let needsRecovery = false; - /** @type {{observe:(notification:unknown,observedAt:string)=>Promise>,markGap:()=>void,markTerminal:()=>void}} */ + /** @type {{observe:(notification:unknown,observedAt:string)=>Promise,markGap:()=>void,markTerminal:()=>void}} */ const api = { observe(notification, observedAt) { - if (terminal) return Promise.resolve([]); + if (terminal) return Promise.resolve(ignored('terminal')); return new Promise((resolveResult) => { - if (active && pending.length >= MAX_PENDING_OBSERVATIONS) { markGap(); resolveResult([]); return; } + if (active && pending.length >= MAX_PENDING_OBSERVATIONS) { markGap(); resolveResult(ignored('overflow')); return; } pending.push({ notification, observedAt, resolve: resolveResult }); drain(); }); @@ -66,51 +74,61 @@ export async function createConversationProgressDescriber({ sessionId, subscript return api; function markGap() { if (!terminal) needsRecovery = true; } - function latchTerminal() { terminal = true; while (pending.length > 0) pending.shift()?.resolve([]); } + function latchTerminal() { terminal = true; while (pending.length > 0) pending.shift()?.resolve(ignored('terminal')); } function drain() { if (active || pending.length === 0) return; const item = pending.shift(); if (!item) return; active = true; - Promise.resolve().then(() => observeFrame(item.notification, item.observedAt)).catch(() => []).then((events) => item.resolve(events)).finally(() => { active = false; drain(); }); + Promise.resolve().then(() => observeFrame(item.notification, item.observedAt)).catch(() => rejected('row-shape')).then((result) => item.resolve(result)).finally(() => { active = false; drain(); }); } - /** @param {unknown} notification @param {unknown} observedAt */ + /** @param {unknown} notification @param {unknown} observedAt @returns {Promise} */ async function observeFrame(notification, observedAt) { - if (terminal || !validObservedAt(observedAt)) return []; + if (terminal) return ignored('terminal'); + if (!validObservedAt(observedAt)) return rejected('envelope-shape'); + const publicObservedAt = /** @type {string} */ (observedAt); const validated = validateNotification(notification, topic, subscriptionId); - if (!validated || validated.deliveryKind === 'initial') return []; - if (validated.deliveryKind === 'recovery') { - if (lastOrdinal !== undefined && (validated.ordinal <= lastOrdinal || validated.toSeq <= /** @type {number} */ (lastSeq))) return []; - if (!needsRecovery && lastOrdinal !== undefined && (validated.ordinal !== lastOrdinal + 1 || validated.fromSeq !== /** @type {number} */ (lastSeq) + 1)) return []; - lastOrdinal = validated.ordinal; lastSeq = validated.toSeq; needsRecovery = false; - absorbRecovery(validated.deltas); return []; + if (!validated.ok) return rejected(validated.reason); + const frame = validated.value; + if (frame.deliveryKind === 'initial') return accepted('initial'); + if (frame.deliveryKind === 'recovery') { + if (lastOrdinal !== undefined && (frame.ordinal <= lastOrdinal || frame.toSeq <= /** @type {number} */ (lastSeq))) { + needsRecovery = true; return rejected('sequence'); + } + if (!needsRecovery && lastOrdinal !== undefined && (frame.ordinal !== lastOrdinal + 1 || frame.fromSeq !== /** @type {number} */ (lastSeq) + 1)) { + needsRecovery = true; return rejected('sequence'); + } + lastOrdinal = frame.ordinal; lastSeq = frame.toSeq; needsRecovery = false; + absorbRecovery(frame.deltas); return accepted('recovery'); + } + if (lastOrdinal !== undefined && (frame.ordinal !== lastOrdinal + 1 || frame.fromSeq !== /** @type {number} */ (lastSeq) + 1)) { + needsRecovery = true; return rejected('sequence'); } - if (needsRecovery) return []; - if (lastOrdinal !== undefined && (validated.ordinal !== lastOrdinal + 1 || validated.fromSeq !== /** @type {number} */ (lastSeq) + 1)) return []; + if (needsRecovery) return ignored('recovery-required'); const staged = []; - for (const delta of validated.deltas) { + for (const delta of frame.deltas) { if (!delta.row) continue; if (delta.row.kind === 'toolCall') { - const event = await describeTool(delta.row, toolStates, workspaceRoot, /** @type {string} */ (observedAt), resolvePath, pathTimeoutMs, () => terminal || needsRecovery); - if (terminal || needsRecovery) return []; + const event = await describeTool(delta.row, toolStates, workspaceRoot, publicObservedAt, resolvePath, pathTimeoutMs, () => terminal || needsRecovery); + if (terminal || needsRecovery) return ignored(terminal ? 'terminal' : 'recovery-required'); if (event) staged.push(event); } else { const row = delta.row; const previous = rowStates.get(row.rowId); if (row.state === 'completedSuccess' || row.state === 'failed' || row.state === 'completedInterrupted') { if (previous !== undefined || rowStates.size < MAX_TRACKED_ROWS) rowStates.set(row.rowId, row.state); - staged.push({ phase: 'finalizing', message: row.state === 'completedSuccess' ? 'ZCode turn completed.' : 'ZCode turn ended without success.', observedAt }); + staged.push({ phase: 'finalizing', message: row.state === 'completedSuccess' ? 'ZCode turn completed.' : 'ZCode turn ended without success.', observedAt: publicObservedAt }); latchTerminal(); break; } if (previous === undefined && rowStates.size >= MAX_TRACKED_ROWS) continue; rowStates.set(row.rowId, row.state); if (previous === row.state) continue; - if (row.state === 'running' && previous === undefined) staged.push({ phase: 'starting', message: 'ZCode turn started.', observedAt }); + if (row.state === 'running' && previous === undefined) staged.push({ phase: 'starting', message: 'ZCode turn started.', observedAt: publicObservedAt }); } } - lastOrdinal = validated.ordinal; lastSeq = validated.toSeq; - return staged; + lastOrdinal = frame.ordinal; lastSeq = frame.toSeq; + return accepted('online', staged); } /** @param {Array<{op:string,row?:any}>} deltas */ @@ -141,14 +159,18 @@ export async function createConversationProgressDescriber({ sessionId, subscript */ export function createDeferredConversationProgressObserver({ sessionId, workspace }) { /** @type {Awaited>|undefined} */ let describer; - /** @type {Array<{notification:unknown,observedAt:string,resolve:(events:any[])=>void}>} */ const buffered = []; + /** @type {Array<{notification:unknown,observedAt:string,resolve:(result:ObservationResult)=>void}>} */ const buffered = []; let binding = false; let disabled = false; let terminal = false; let prebindGap = false; - const resolveBufferedEmpty = () => { while (buffered.length > 0) buffered.shift()?.resolve([]); }; - return /** @type {{observe:(notification:unknown,observedAt:string)=>Promise,bind:(subscriptionId:string)=>Promise,fail:()=>void,markGap:()=>void,markTerminal:()=>void}} */ ({ + /** @param {string} [reason] */ + const resolveBufferedEmpty = (reason = terminal ? 'terminal' : 'disabled') => { while (buffered.length > 0) buffered.shift()?.resolve(ignored(reason)); }; + return /** @type {{observe:(notification:unknown,observedAt:string)=>Promise,bind:(subscriptionId:string)=>Promise,fail:()=>void,markGap:()=>void,markTerminal:()=>void}} */ ({ observe(notification, observedAt) { - if (terminal || disabled) return Promise.resolve([]); + if (terminal || disabled) return Promise.resolve(ignored(terminal ? 'terminal' : 'disabled')); if (describer && !binding) return describer.observe(notification, observedAt); - if (buffered.length >= MAX_PENDING_OBSERVATIONS) return Promise.resolve([]); + if (buffered.length >= MAX_PENDING_OBSERVATIONS) { + if (describer) describer.markGap(); else prebindGap = true; + return Promise.resolve(ignored('overflow')); + } return new Promise((resolveResult) => buffered.push({ notification, observedAt, resolve: resolveResult })); }, async bind(subscriptionId) { @@ -156,11 +178,11 @@ export function createDeferredConversationProgressObserver({ sessionId, workspac binding = true; try { describer = await createConversationProgressDescriber({ sessionId, subscriptionId, workspace }); - if (prebindGap) describer.markGap(); while (!terminal && !disabled && buffered.length > 0) { const item = buffered.shift(); if (!item) break; item.resolve(await describer.observe(item.notification, item.observedAt)); } + if (prebindGap) describer.markGap(); } catch (error) { disabled = true; resolveBufferedEmpty(); throw error; } finally { binding = false; if (terminal || disabled) resolveBufferedEmpty(); } @@ -171,43 +193,46 @@ export function createDeferredConversationProgressObserver({ sessionId, workspac if (describer) describer.markGap(); else if (prebindGap) return; else prebindGap = true; - resolveBufferedEmpty(); + resolveBufferedEmpty('recovery-required'); }, markTerminal() { terminal = true; describer?.markTerminal(); resolveBufferedEmpty(); }, }); } -/** @param {unknown} notification @param {string} topic @param {string} subscriptionId @returns {{deliveryKind:string,ordinal:number,fromSeq:number,toSeq:number,deltas:Array<{op:string,row?:any}>}|null} */ +/** @param {unknown} notification @param {string} topic @param {string} subscriptionId @returns {{ok:true,value:{deliveryKind:'initial'|'online'|'recovery',ordinal:number,fromSeq:number,toSeq:number,deltas:Array<{op:string,row:Record}>}}|{ok:false,reason:string}} */ function validateNotification(notification, topic, subscriptionId) { - if (!plainObject(notification) || notification.method !== 'v4/conversation/frame' || !plainObject(notification.params)) return null; + if (!plainObject(notification) || notification.method !== 'v4/conversation/frame' || !plainObject(notification.params)) return { ok: false, reason: 'envelope-shape' }; const wire = notification.params; - if (!exactKeys(wire, ['wireVersion', 'kind', 'deliveryKind', 'logicalFrameId', 'logicalFrameOrdinal', 'topic', 'subscriptionId', 'frame']) - || wire.wireVersion !== CONVERSATION_WIRE_VERSION || wire.kind !== 'complete' + if (!exactKeys(wire, ['wireVersion', 'kind', 'deliveryKind', 'logicalFrameId', 'logicalFrameOrdinal', 'topic', 'subscriptionId', 'frame'])) return { ok: false, reason: 'envelope-shape' }; + if (wire.wireVersion !== CONVERSATION_WIRE_VERSION) return { ok: false, reason: 'wire-version' }; + if (wire.topic !== topic || wire.subscriptionId !== subscriptionId) return { ok: false, reason: 'topic' }; + if (wire.kind !== 'complete' || !['initial', 'online', 'recovery'].includes(wire.deliveryKind) - || !boundedIdentifier(wire.logicalFrameId, 256) || !positiveInteger(wire.logicalFrameOrdinal) - || wire.topic !== topic || wire.subscriptionId !== subscriptionId || !plainObject(wire.frame)) return null; + || !boundedIdentifier(wire.logicalFrameId, 256) || !plainObject(wire.frame)) return { ok: false, reason: 'envelope-shape' }; + if (!positiveInteger(wire.logicalFrameOrdinal)) return { ok: false, reason: 'sequence' }; const frame = wire.frame; - if (!exactKeys(frame, ['topic', 'subscriptionId', 'fromSeq', 'toSeq', 'sentAt', 'payload']) - || frame.topic !== topic || frame.subscriptionId !== subscriptionId - || !nonnegativeInteger(frame.fromSeq) || !nonnegativeInteger(frame.toSeq) || frame.toSeq < frame.fromSeq - || !wireTimestamp(frame.sentAt) || !plainObject(frame.payload) + if (!exactKeys(frame, ['topic', 'subscriptionId', 'fromSeq', 'toSeq', 'sentAt', 'payload'])) return { ok: false, reason: 'envelope-shape' }; + if (frame.topic !== topic || frame.subscriptionId !== subscriptionId) return { ok: false, reason: 'topic' }; + if (!nonnegativeInteger(frame.fromSeq) || !nonnegativeInteger(frame.toSeq) || frame.toSeq < frame.fromSeq) return { ok: false, reason: 'sequence' }; + if (!wireTimestamp(frame.sentAt) || !plainObject(frame.payload) || !exactKeys(frame.payload, ['kind', 'deltas']) || frame.payload.kind !== 'deltas' - || !Array.isArray(frame.payload.deltas) || frame.payload.deltas.length > MAX_DELTAS_PER_FRAME) return null; + || !Array.isArray(frame.payload.deltas) || frame.payload.deltas.length > MAX_DELTAS_PER_FRAME) return { ok: false, reason: 'envelope-shape' }; const deltas = []; for (const value of frame.payload.deltas) { - const delta = validateDelta(value); if (!delta) return null; deltas.push(delta); + const delta = validateDelta(value); if (!delta.ok) return delta; deltas.push(delta.value); } - return { deliveryKind: wire.deliveryKind, ordinal: wire.logicalFrameOrdinal, fromSeq: frame.fromSeq, toSeq: frame.toSeq, deltas }; + return { ok: true, value: { deliveryKind: wire.deliveryKind, ordinal: wire.logicalFrameOrdinal, fromSeq: frame.fromSeq, toSeq: frame.toSeq, deltas } }; } -/** @param {unknown} value */ +/** @param {unknown} value @returns {{ok:true,value:{op:string,row:Record}}|{ok:false,reason:string}} */ function validateDelta(value) { - if (!plainObject(value) || typeof value.op !== 'string') return null; + if (!plainObject(value) || typeof value.op !== 'string') return { ok: false, reason: 'row-shape' }; if (value.op === 'row.appended' || value.op === 'row.upserted') { - if (!exactKeys(value, ['op', 'row']) || !plainObject(value.row)) return null; - const row = validateRow(value.row); return row ? { op: value.op, row } : null; + if (!exactKeys(value, ['op', 'row']) || !plainObject(value.row)) return { ok: false, reason: 'row-shape' }; + if (!['toolCall', 'turnHeader'].includes(value.row.kind)) return { ok: false, reason: 'row-kind' }; + const row = validateRow(value.row); return row ? { ok: true, value: { op: value.op, row } } : { ok: false, reason: 'row-shape' }; } - return null; + return { ok: false, reason: 'row-kind' }; } /** @param {Record} row */ @@ -250,33 +275,39 @@ async function describeTool(row, states, workspaceRoot, observedAt, resolvePath, const status = row.status; if (START_STATUSES.has(status)) { if (prior.started || !states.has(key) && states.size >= MAX_TRACKED_ROWS) return null; - const message = fitProgressMessage(await startMessage(row, workspaceRoot, resolvePath, timeoutMs)); + const message = fitProgressMessage(await formatToolStartMessage(row, workspaceRoot, resolvePath, timeoutMs)); if (isTerminal()) return null; states.set(key, { started: true, terminal: false, message }); return { phase: status === 'pendingApproval' ? 'waiting' : 'running', message, observedAt }; } if (!SUCCESS_STATUSES.has(status) && !FAILURE_STATUSES.has(status)) return null; if (!states.has(key) && states.size >= MAX_TRACKED_ROWS) return null; - const startMessageValue = prior.message ?? await startMessage(row, workspaceRoot, resolvePath, timeoutMs); + const startMessageValue = prior.message ?? await formatToolStartMessage(row, workspaceRoot, resolvePath, timeoutMs); if (isTerminal()) return null; states.set(key, { started: prior.started, terminal: true, message: startMessageValue }); const duration = durationSuffix(row.startedAt, row.endedAt); - return { phase: 'running', message: fitProgressMessage(terminalMessage(row, startMessageValue, SUCCESS_STATUSES.has(status), duration)), observedAt }; + return { phase: 'running', message: fitProgressMessage(formatToolTerminalMessage(row, startMessageValue, SUCCESS_STATUSES.has(status), duration)), observedAt }; } /** @param {any} row @param {string} workspaceRoot @param {(value:unknown,root:string)=>Promise} resolvePath @param {number} timeoutMs */ -async function startMessage(row, workspaceRoot, resolvePath, timeoutMs) { +export async function formatToolStartMessage(row, workspaceRoot, resolvePath = containedRelativePath, timeoutMs = PATH_RESOLUTION_TIMEOUT_MS, allowTextPreviews = true) { + return formatToolStartMessageWithOptions(row, workspaceRoot, { resolvePath, timeoutMs, allowTextPreviews }); +} + +/** @param {any} row @param {string} workspaceRoot @param {{resolvePath:(value:unknown,root:string)=>Promise,timeoutMs:number,allowTextPreviews:boolean}} options */ +async function formatToolStartMessageWithOptions(row, workspaceRoot, { resolvePath, timeoutMs, allowTextPreviews }) { const toolName = normalizePreview(row.toolName, 64); const input = plainObject(row.input) ? row.input : {}; - if (toolName === 'Bash') { const preview = normalizePreview(input.command); return preview ? `Running command: ${preview}.` : 'Running tool: Bash.'; } + if (!toolName) return 'Running a tool.'; + if (toolName === 'Bash') { const preview = allowTextPreviews ? normalizePreview(input.command) : ''; return preview ? `Running command: ${preview}.` : 'Running tool: Bash.'; } if (['Read', 'Edit', 'Write'].includes(toolName)) { const path = await boundedPath(resolvePath, input.file_path, workspaceRoot, timeoutMs); if (!path) return `Running tool: ${toolName}.`; return `${toolName === 'Read' ? 'Reading' : toolName === 'Edit' ? 'Editing' : 'Writing'}: ${path}.`; } - if (toolName === 'Grep') { const preview = normalizePreview(input.pattern); return preview ? `Searching files: ${preview}.` : 'Running tool: Grep.'; } - if (toolName === 'Glob') { const preview = normalizePreview(input.pattern); return preview ? `Finding files: ${preview}.` : 'Running tool: Glob.'; } - if (toolName === 'WebSearch') { const preview = normalizePreview(input.query); return preview ? `Searching the web: ${preview}.` : 'Running tool: WebSearch.'; } + if (toolName === 'Grep') { const preview = allowTextPreviews ? normalizePreview(input.pattern) : ''; return preview ? `Searching files: ${preview}.` : 'Running tool: Grep.'; } + if (toolName === 'Glob') { const preview = allowTextPreviews ? normalizePreview(input.pattern) : ''; return preview ? `Finding files: ${preview}.` : 'Running tool: Glob.'; } + if (toolName === 'WebSearch') { const preview = allowTextPreviews ? normalizePreview(input.query) : ''; return preview ? `Searching the web: ${preview}.` : 'Running tool: WebSearch.'; } return `Running tool: ${toolName}.`; } @@ -290,13 +321,14 @@ async function boundedPath(resolvePath, value, root, timeoutMs) { } /** @param {any} row @param {string} started @param {boolean} succeeded @param {string} duration */ -function terminalMessage(row, started, succeeded, duration) { +export function formatToolTerminalMessage(row, started, succeeded, duration = durationSuffix(row.startedAt, row.endedAt)) { const state = succeeded ? 'completed' : 'failed'; if (started.startsWith('Running command: ')) { const value = started.slice(17); const command = value.endsWith('.') ? value.slice(0, -1) : value; return `Command ${state}: ${command}${duration}.`; } - return `${normalizePreview(row.toolName, 64)} ${state}${duration}.`; + const toolName = normalizePreview(row.toolName, 64); + return `${toolName || 'Tool'} ${state}${duration}.`; } /** @param {unknown} startedAt @param {unknown} endedAt */ @@ -307,7 +339,7 @@ function durationSuffix(startedAt, endedAt) { } /** @param {string} message */ -function fitProgressMessage(message) { +export function fitProgressMessage(message) { if (Buffer.byteLength(message) <= MAX_PUBLIC_MESSAGE_BYTES) return message; let output = ''; for (const character of message) { diff --git a/marketplace/plugins/zcode/scripts/lib/progress.mjs b/marketplace/plugins/zcode/scripts/lib/progress.mjs index d133fc7e..ecd930cb 100644 --- a/marketplace/plugins/zcode/scripts/lib/progress.mjs +++ b/marketplace/plugins/zcode/scripts/lib/progress.mjs @@ -4,6 +4,8 @@ export const MAX_PROGRESS_PENDING_EVENTS = 4; export const MAX_PROGRESS_MESSAGE_BYTES = 256; export const PROGRESS_HEARTBEAT_MS = 20_000; export const MAX_PROGRESS_DIAGNOSTIC_KINDS = 8; +export const MAX_PROGRESS_PROBE_COUNT = 255; +export const PROGRESS_PROBE_REJECTION_THRESHOLD = 4; const PROGRESS_FLUSH_TIMEOUT_MS = 250; const PROGRESS_SEMANTIC_GRACE_MS = 125; @@ -15,8 +17,12 @@ const PROGRESS_DIAGNOSTICS = new Map([ ['writer-disabled', 'ZCode progress output was disabled.'], ['preview-disabled', 'ZCode progress preview was disabled.'], ['progress-flush-timeout', 'ZCode progress cleanup reached its time limit.'], + ['conversation-snapshot-fallback', 'ZCode conversation frames were unavailable; using bounded session progress.'], + ['conversation-lifecycle-only', 'ZCode semantic progress is unavailable; lifecycle updates will continue.'], ]); +const PROBE_REJECTION_REASONS = Object.freeze(['wire-version', 'envelope-shape', 'sequence', 'topic', 'row-kind', 'row-shape']); + /** @template T @param {Promise} completion @param {AbortSignal|undefined} signal @returns {Promise} */ export async function waitForCompletionOrAbort(completion, signal) { const completionPromise = Promise.resolve(completion); @@ -58,13 +64,15 @@ export function normalizeZCodeProgress(notification, sessionId, observedAt) { } /** - * @param {{sessionId:string,deferred?:boolean,write?:(line:string)=>void,persist?:(event:{phase:string,message:string,observedAt:string})=>Promise|void,describeNotification?:(notification:unknown,observedAt:string)=>Array<{phase:string,message:string,observedAt:string}>|Promise>,onDescriptorOverflow?:()=>void,onDiagnostic?:(diagnostic:{kind:string})=>void,now?:()=>string,setInterval?:(callback:()=>void,milliseconds:number)=>any,clearInterval?:(timer:any)=>void}} options + * @param {{sessionId:string,deferred?:boolean,write?:(line:string)=>void,persist?:(event:{phase:string,message:string,observedAt:string})=>Promise|void,persistProbe?:(probe:any)=>Promise|void,activateSnapshotFallback?:()=>false|(()=>unknown),describeNotification?:(notification:unknown,observedAt:string)=>any|Promise,onDescriptorOverflow?:()=>void,onDiagnostic?:(diagnostic:{kind:string})=>void,now?:()=>string,setInterval?:(callback:()=>void,milliseconds:number)=>any,clearInterval?:(timer:any)=>void}} options */ export function createProgressReporter({ sessionId, deferred = false, write, persist, + persistProbe, + activateSnapshotFallback: configuredSnapshotFallback, describeNotification, onDescriptorOverflow, onDiagnostic, @@ -97,6 +105,51 @@ export function createProgressReporter({ let observationSequence = 0; /** @type {number|null} */ let terminalSequence = null; let terminalDispatched = false; + const progressProbe = { + state: 'probing', subscriptionAcknowledged: false, framesReceived: 0, + acceptedInitial: 0, acceptedOnline: 0, acceptedRecovery: 0, + rejected: Object.fromEntries(PROBE_REJECTION_REASONS.map((reason) => [reason, 0])), + snapshotFallbackActive: false, snapshotFallbackUnavailable: false, + }; + let compatibilityBoundaryActivated = false; + let acceptedBoundaryActivated = typeof configuredSnapshotFallback === 'function'; + /** @type {undefined|(()=>false|(()=>unknown))} */ let activateSnapshotFallback = configuredSnapshotFallback; + /** @type {null|(()=>unknown)} */ let snapshotFallbackCleanup = null; + /** @type {null|(()=>Promise)} */ let snapshotRead = null; + /** @type {null|{observe:(snapshot:unknown,observedAt:string)=>unknown|Promise}} */ let snapshotDescriber = null; + /** @type {Promise|null} */ let snapshotReadInFlight = null; + let snapshotEpoch = 0; + let adjacentHeartbeatClaimed = false; + const cleanupSnapshotFallback = () => { + const cleanup = snapshotFallbackCleanup; snapshotFallbackCleanup = null; + if (cleanup === null) return false; + try { Promise.resolve(cleanup()).catch(() => {}); } catch { /* fallback cleanup is observational */ } + return true; + }; + const probeSnapshot = () => ({ ...progressProbe, rejected: { ...progressProbe.rejected } }); + /** @type {Promise|null} */ let probePersistInFlight = null; + /** @type {any|null} */ let probePersistPending = null; + /** @param {any} snapshot */ + const startProbePersist = (snapshot) => { + let operation; + try { operation = Promise.resolve(/** @type {(probe:any)=>Promise|void} */ (persistProbe)(snapshot)); } + catch { operation = Promise.reject(new Error('progress probe persistence failed')); } + const tracked = operation.catch(() => {}).then(() => { + if (probePersistInFlight !== tracked) return; + probePersistInFlight = null; + if (closed || probePersistPending === null) { probePersistPending = null; return; } + const next = probePersistPending; probePersistPending = null; startProbePersist(next); + }); + probePersistInFlight = tracked; + }; + const persistProbeSnapshot = () => { + if (closed || typeof persistProbe !== 'function') return; + const snapshot = probeSnapshot(); + if (probePersistInFlight === null) startProbePersist(snapshot); + else probePersistPending = snapshot; + }; + /** @param {number} value */ + const saturatingIncrement = (value) => Math.min(MAX_PROGRESS_PROBE_COUNT, value + 1); const diagnosedKinds = new Set(); /** @param {string} kind */ const diagnose = (kind) => { @@ -117,18 +170,94 @@ export function createProgressReporter({ /** @type {any} */ let timer = null; const startTimer = () => { - if (timer !== null || typeof write !== 'function') return; + if (timer !== null || typeof write !== 'function' && typeof persistProbe !== 'function' && typeof activateSnapshotFallback !== 'function') return; timer = setIntervalFn(() => { + activateCompatibilityBoundary(true); + if (progressProbe.state === 'snapshot-fallback') { + if (adjacentHeartbeatClaimed) adjacentHeartbeatClaimed = false; + else startSnapshotRead(false); + } const currentTime = now(); if (!validTimestamp(currentTime) || !validTimestamp(lastActivityAt)) return; const elapsedMs = Date.parse(currentTime) - Date.parse(lastActivityAt); if (elapsedMs < PROGRESS_HEARTBEAT_MS) return; const seconds = Math.floor(elapsedMs / 1_000); - try { if (!writerDisabled) write(`[zcode] Still waiting for ZCode; last activity ${seconds}s ago.\n`); } + try { if (!writerDisabled && typeof write === 'function') write(`[zcode] Still waiting for ZCode; last activity ${seconds}s ago.\n`); } catch { writerDisabled = true; diagnose('writer-disabled'); } }, PROGRESS_HEARTBEAT_MS); timer?.unref?.(); }; + /** @param {boolean} requireAcceptedBoundary */ + const activateCompatibilityBoundary = (requireAcceptedBoundary = false) => { + if (closed || !accepting || requireAcceptedBoundary && !acceptedBoundaryActivated || compatibilityBoundaryActivated || progressProbe.state !== 'probing' || progressProbe.acceptedOnline > 0) return false; + compatibilityBoundaryActivated = true; + /** @type {unknown} */ let activation = false; + try { activation = typeof activateSnapshotFallback === 'function' ? activateSnapshotFallback() : false; } catch { activation = false; } + if (typeof activation === 'function') { + snapshotFallbackCleanup = /** @type {()=>unknown} */ (activation); + progressProbe.state = 'snapshot-fallback'; progressProbe.snapshotFallbackActive = true; + diagnose('conversation-snapshot-fallback'); + startSnapshotRead(); + } else { + try { if (activation !== null && typeof activation === 'object') Promise.resolve(activation).catch(() => {}); } + catch { /* fallback activation failures are observational */ } + progressProbe.state = 'lifecycle-only'; progressProbe.snapshotFallbackUnavailable = true; + diagnose('conversation-lifecycle-only'); + } + persistProbeSnapshot(); return true; + }; + /** @param {boolean} claimAdjacentHeartbeat */ + const startSnapshotRead = (claimAdjacentHeartbeat = true) => { + if (closed || !accepting || progressProbe.state !== 'snapshot-fallback' || snapshotReadInFlight !== null + || snapshotRead === null || snapshotDescriber === null) return false; + const epoch = snapshotEpoch; + const read = snapshotRead; const describer = snapshotDescriber; + let operation; + try { operation = Promise.resolve().then(() => read()); } + catch { operation = Promise.reject(new Error('snapshot progress read failed')); } + const tracked = operation.then(async (snapshot) => { + if (closed || !accepting || epoch !== snapshotEpoch || progressProbe.state !== 'snapshot-fallback') return; + const observedAt = now(); if (!validTimestamp(observedAt)) throw new Error('snapshot progress timestamp invalid'); + const events = await describer.observe(snapshot, observedAt); + if (closed || !accepting || epoch !== snapshotEpoch || progressProbe.state !== 'snapshot-fallback') return; + if (!Array.isArray(events)) throw new Error('snapshot progress description invalid'); + const boundedEvents = events.slice(0, MAX_PROGRESS_PENDING_EVENTS); + if (!boundedEvents.every(validPublicEvent)) throw new Error('snapshot progress event invalid'); + for (const event of boundedEvents) { + const sequence = observationSequence; observationSequence += 1; + enqueueLogical({ kind: 'event', event, sequence }); + } + }).catch(() => { + if (closed || !accepting || epoch !== snapshotEpoch || progressProbe.state !== 'snapshot-fallback') return; + cleanupSnapshotFallback(); + progressProbe.state = 'lifecycle-only'; progressProbe.snapshotFallbackActive = false; progressProbe.snapshotFallbackUnavailable = true; + diagnose('conversation-lifecycle-only'); persistProbeSnapshot(); + }).then(() => { if (snapshotReadInFlight === tracked) snapshotReadInFlight = null; }); + snapshotReadInFlight = tracked; + if (claimAdjacentHeartbeat) adjacentHeartbeatClaimed = true; + return true; + }; + /** @param {unknown} result @param {number} epoch */ + const recordDescriptionResult = (result, epoch) => { + if (!accepting || epoch !== descriptorEpoch) return []; + if (!plainObject(result) || !Array.isArray(result.events)) return []; + if (result.disposition === 'accepted' && ['initial', 'online', 'recovery'].includes(result.phase)) { + const field = result.phase === 'initial' ? 'acceptedInitial' : result.phase === 'online' ? 'acceptedOnline' : 'acceptedRecovery'; + progressProbe[field] = saturatingIncrement(progressProbe[field]); + if (result.phase === 'online') { + cleanupSnapshotFallback(); + progressProbe.state = 'online'; progressProbe.snapshotFallbackActive = false; progressProbe.snapshotFallbackUnavailable = false; + } + persistProbeSnapshot(); return result.events; + } + if (result.disposition === 'rejected' && PROBE_REJECTION_REASONS.includes(result.reason)) { + progressProbe.rejected[result.reason] = saturatingIncrement(progressProbe.rejected[result.reason]); + persistProbeSnapshot(); + const total = PROBE_REJECTION_REASONS.reduce((sum, reason) => sum + progressProbe.rejected[reason], 0); + if (total >= PROGRESS_PROBE_REJECTION_THRESHOLD) activateCompatibilityBoundary(true); + } + return []; + }; /** @param {{event:{phase:string,message:string,observedAt:string},sequence:number}} entry */ const startWriter = (entry) => { if (typeof write !== 'function' || writerDisabled) return; @@ -227,11 +356,16 @@ export function createProgressReporter({ let described; try { described = Promise.resolve(describeNotification(item.notification, item.observedAt)); } catch { diagnose('conversation-render-failed'); described = Promise.resolve([]); } - descriptorInFlight = described.then((events) => { - if (epoch !== descriptorEpoch || !Array.isArray(events) || closed) return; - item.events = events.slice(0, MAX_PROGRESS_PENDING_EVENTS).filter(validPublicEvent); + const tracked = described.then((description) => { + if (closed) return; + const current = accepting && epoch === descriptorEpoch; + const events = current + ? Array.isArray(description) ? description : recordDescriptionResult(description, epoch) + : Array.isArray(description) ? description : plainObject(description) && description.disposition === 'accepted' && Array.isArray(description.events) ? description.events : []; + if (!Array.isArray(events)) return; + item.events = events.slice(0, MAX_PROGRESS_PENDING_EVENTS).filter((event) => validPublicEvent(event) && (current || event.phase !== 'finalizing')); for (const describedEvent of item.events) { - if (describedEvent.phase === 'finalizing') { + if (current && describedEvent.phase === 'finalizing') { terminalSequence = terminalSequence === null ? item.sequence : Math.min(terminalSequence, item.sequence); for (const later of logicalPending) if (later.sequence > terminalSequence) { if (later.kind === 'descriptor') later.state = 'dropped'; @@ -239,10 +373,15 @@ export function createProgressReporter({ } } item.state = 'ready'; - }).catch(() => { if (epoch === descriptorEpoch) { item.state = 'dropped'; diagnose('conversation-render-failed'); } }).then(() => { - if (epoch !== descriptorEpoch) return; - descriptorInFlight = null; activeDescriptor = null; pumpLogical(); + }).catch(() => { + item.state = 'dropped'; + if (accepting && epoch === descriptorEpoch) diagnose('conversation-render-failed'); + }).then(() => { + if (descriptorInFlight === tracked) descriptorInFlight = null; + if (activeDescriptor === item) activeDescriptor = null; + pumpLogical(); }); + descriptorInFlight = tracked; }; /** @param {{notification:unknown,observedAt:string,sequence:number}} item */ const enqueueDescribe = (item) => { @@ -282,6 +421,35 @@ export function createProgressReporter({ if (active) startTimer(); return { + markConversationSubscribed() { + if (closed || !accepting || progressProbe.subscriptionAcknowledged) return false; + progressProbe.subscriptionAcknowledged = true; persistProbeSnapshot(); return true; + }, + activateCompatibilityBoundary: () => activateCompatibilityBoundary(false), + /** @param {{readSnapshot:()=>Promise,describer:{observe:(snapshot:unknown,observedAt:string)=>unknown|Promise}}} boundary */ + activateAcceptedBoundary(boundary) { + if (closed || acceptedBoundaryActivated) return false; + acceptedBoundaryActivated = true; + if (typeof boundary?.readSnapshot === 'function' && typeof boundary?.describer?.observe === 'function') { + snapshotRead = boundary.readSnapshot; snapshotDescriber = boundary.describer; + activateSnapshotFallback = () => { + if (closed || snapshotRead === null || snapshotDescriber === null) return false; + const epoch = snapshotEpoch + 1; snapshotEpoch = epoch; + startSnapshotRead(); + let cleaned = false; + return () => { + if (cleaned) return; + cleaned = true; + if (snapshotEpoch === epoch) snapshotEpoch += 1; + snapshotReadInFlight = null; + }; + }; + } + const rejectedTotal = PROBE_REJECTION_REASONS.reduce((sum, reason) => sum + progressProbe.rejected[reason], 0); + if (rejectedTotal >= PROGRESS_PROBE_REJECTION_THRESHOLD) activateCompatibilityBoundary(true); + return true; + }, + probeSnapshot, /** @param {unknown} notification */ observe(notification) { if (closed || !accepting) return null; @@ -290,6 +458,7 @@ export function createProgressReporter({ const observedAt = now(); const event = normalizeZCodeProgress(notification, sessionId, observedAt); if (event === null && typeof describeNotification === 'function' && plainObject(notification) && notification.method === 'v4/conversation/frame') { + progressProbe.framesReceived = saturatingIncrement(progressProbe.framesReceived); persistProbeSnapshot(); enqueueDescribe({ notification, observedAt, sequence }); return null; } if (event === null) return null; @@ -301,6 +470,7 @@ export function createProgressReporter({ activate(initialNotification) { if (active || closed) return false; const activatedAt = now(); active = true; lastActivityAt = activatedAt; startTimer(); + persistProbeSnapshot(); const initial = normalizeZCodeProgress(initialNotification, sessionId, activatedAt); if (initial) dispatch(initial, -1); for (const { event, sequence } of buffered.sort((left, right) => left.sequence - right.sequence)) dispatch({ ...event, observedAt: activatedAt }, sequence); @@ -308,7 +478,12 @@ export function createProgressReporter({ }, /** @param {string} kind */ diagnose(kind) { return diagnose(kind); }, - stopAccepting() { accepting = false; }, + stopAccepting() { + if (!accepting) return; + accepting = false; descriptorEpoch += 1; + for (const item of logicalPending) if (item.kind === 'descriptor' && item.state === 'pending' && item !== activeDescriptor) item.state = 'dropped'; + descriptorOverflowed = false; pumpLogical(); + }, async flush(absoluteDeadline = Date.now() + PROGRESS_FLUSH_TIMEOUT_MS) { const deadline = Math.min(absoluteDeadline, Date.now() + PROGRESS_FLUSH_TIMEOUT_MS); await Promise.resolve(); @@ -321,23 +496,27 @@ export function createProgressReporter({ diagnose('progress-flush-timeout'); await Promise.resolve(); } const sinkBudget = remaining(deadline); - const [writerDrained, persistenceDrained] = await Promise.all([ - waitWithin(drainWriter(), sinkBudget), waitWithin(drainPersistence(), sinkBudget), + const [writerDrained, persistenceDrained, probePersistenceDrained] = await Promise.all([ + waitWithin(drainWriter(), sinkBudget), waitWithin(drainPersistence(), sinkBudget), waitWithin(drainProbePersistence(), sinkBudget), ]); - if (!writerDrained || !persistenceDrained) { + if (!writerDrained || !persistenceDrained || !probePersistenceDrained) { diagnose('progress-flush-timeout'); await Promise.resolve(); if (!writerDrained) disableWriter(); if (!persistenceDrained) disablePersist(); + if (!probePersistenceDrained) disableProbePersist(); const finalBudget = remaining(deadline); await Promise.all([ writerDisabled ? Promise.resolve() : waitWithin(drainWriter(), finalBudget), persistDisabled ? Promise.resolve() : waitWithin(drainPersistence(), finalBudget), + probePersistInFlight === null ? Promise.resolve() : waitWithin(drainProbePersistence(), finalBudget), ]); } return true; }, close() { + cleanupSnapshotFallback(); accepting = false; closed = true; buffered.length = 0; bufferedKeys.clear(); + disableProbePersist(); descriptorEpoch += 1; for (const item of logicalPending) if (item.kind === 'descriptor') item.state = 'dropped'; activeDescriptor = null; descriptorInFlight = null; pumpLogical(); logicalPending.length = 0; @@ -367,10 +546,15 @@ export function createProgressReporter({ while (persistInFlight !== null) await persistInFlight; } + async function drainProbePersistence() { + while (probePersistInFlight !== null) await probePersistInFlight; + } + async function drainWriter() { while (writerInFlight !== null) await writerInFlight; } function disableWriter() { writerEpoch += 1; writerDisabled = true; writerInFlight = null; writerPending.length = 0; } function disablePersist() { persistEpoch += 1; persistDisabled = true; persistInFlight = null; persistPending.length = 0; } + function disableProbePersist() { probePersistInFlight = null; probePersistPending = null; } } /** @param {Promise} operation @param {number} milliseconds */ diff --git a/marketplace/plugins/zcode/scripts/lib/render.mjs b/marketplace/plugins/zcode/scripts/lib/render.mjs index 0a7b9faf..e7620b74 100644 --- a/marketplace/plugins/zcode/scripts/lib/render.mjs +++ b/marketplace/plugins/zcode/scripts/lib/render.mjs @@ -1,4 +1,5 @@ import { PluginError } from './errors.mjs'; +import { validProgressProbe } from './state.mjs'; /** @param {unknown} error */ export function errorEnvelope(error) { @@ -8,7 +9,7 @@ export function errorEnvelope(error) { /** @param {any} value @param {{json?:boolean}} [options] */ export function renderOutput(value, options = {}) { - if (options.json) return `${JSON.stringify(redact(value))}\n`; + if (options.json) return `${JSON.stringify(redact(value, exactOwnerJob(value)))}\n`; if (value?.type === 'transfer' && typeof value.result === 'string') return value.result.endsWith('\n') ? value.result : `${value.result}\n`; if (value?.type === 'background') return `Reserved background job ${value.job.id}.\n`; if (value?.jobs) return `${value.jobs.map(renderCompactJob).join('\n')}\n${renderModelPolicy(value.modelPolicy)}`; @@ -143,14 +144,23 @@ function renderModelPolicy(policy) { return policy ? `Model policy: default=${po /** Internal machine transport. Never use for user-facing rendering. @param {unknown} value */ export function renderInternalOutput(value) { return `${JSON.stringify(value)}\n`; } -/** @param {any} value @returns {any} */ -function redact(value) { - if (Array.isArray(value)) return value.map(redact); +/** @param {any} value @returns {any|null} */ +function exactOwnerJob(value) { + return value && typeof value === 'object' && !Array.isArray(value) && !Object.hasOwn(value, 'jobs') + && value.job && typeof value.job === 'object' && !Array.isArray(value.job) + && value.job.owned === true && value.job.owner === 'same-owner' && validProgressProbe(value.job.progressProbe) + ? value.job : null; +} + +/** @param {any} value @param {any|null} [progressProbeOwner] @returns {any} */ +function redact(value, progressProbeOwner = null) { + if (Array.isArray(value)) return value.map((entry) => redact(entry, progressProbeOwner)); if (!value || typeof value !== 'object') return value; /** @type {Record} */ const result = {}; for (const [key, entry] of Object.entries(value)) { if (/token|capability|permissionSnapshot|privateInvocation/i.test(key)) continue; - result[key] = redact(entry); + if (/progressProbe/i.test(key) && (value !== progressProbeOwner || key !== 'progressProbe' || !validProgressProbe(entry))) continue; + result[key] = redact(entry, progressProbeOwner); } return result; } diff --git a/marketplace/plugins/zcode/scripts/lib/review.mjs b/marketplace/plugins/zcode/scripts/lib/review.mjs index 5b5e5830..41f930c0 100644 --- a/marketplace/plugins/zcode/scripts/lib/review.mjs +++ b/marketplace/plugins/zcode/scripts/lib/review.mjs @@ -8,8 +8,10 @@ import { resolveModel } from './args.mjs'; import { ensurePrivateDirectory, withFileLock } from './fs.mjs'; import { collectGitFacts } from './git.mjs'; import { createJobController, withJobCancellationLock } from './job-control.mjs'; +import { isBoundedPublicIdentifier } from './identifier.mjs'; import { createProgressReporter, waitForCompletionOrAbort } from './progress.mjs'; import { createDeferredConversationProgressObserver } from './conversation-progress.mjs'; +import { createSessionProgressDescriber } from './session-progress.mjs'; import { buildPrompt } from './prompts.mjs'; import { loadReviewOutputSchema, validateJsonSchema } from './review-schema.mjs'; import { resolveWorkspaceStorage } from './workspace.mjs'; @@ -98,6 +100,7 @@ export async function executeJob(input) { deferred: true, ...(input.progressWriter ? { write: input.progressWriter } : {}), persist: (event) => input.store.updateJobProgress(workspace, job.id, event), + persistProbe: (probe) => input.store.updateJobProgressProbe(workspace, job.id, probe), describeNotification: conversationObserver.observe, onDescriptorOverflow: conversationObserver.markGap, ...input.progressDependencies, @@ -106,8 +109,10 @@ export async function executeJob(input) { if (typeof client.subscribeConversation === 'function') { try { const conversationSubscription = await client.subscribeConversation(activeSessionId, { connectionId: `companion-${randomBytes(12).toString('hex')}`, clientMode: 'desktop-continuous' }); + if (!conversationSubscription || !isBoundedPublicIdentifier(conversationSubscription.subscriptionId) || typeof conversationSubscription.unsubscribe !== 'function') throw new Error('invalid conversation subscription'); // Register cleanup before binding can perform any asynchronous work. unsubscribeConversation = conversationSubscription.unsubscribe; + reporter.markConversationSubscribed(); await conversationObserver.bind(conversationSubscription.subscriptionId); } catch { conversationObserver.fail(); reporter.diagnose('conversation-subscribe-failed'); } } else conversationObserver.fail(); @@ -124,10 +129,14 @@ export async function executeJob(input) { }); input.signal?.throwIfAborted(); const beforeMessageIds = [...snapshotMessageIds(snapshot)]; sendAttempted = true; const sent = await boundedStep(() => client.send(activeSessionId, prompt), input.signal); - reporter.activate({ method: 'state.updated', params: { scope: 'session', sessionId: activeSessionId, reason: 'prompt_started' } }); running = await input.store.transitionJob(workspace, job.id, ['running'], 'running', { inputId: sent.inputId, startRevision: sent.stateRevision, beforeMessageIds }); await input.onBoundaryPersisted?.(running); const turnBoundary = { beforeMessageIds: new Set(beforeMessageIds), ...sent }; + try { + const sessionDescriber = await createSessionProgressDescriber({ workspace, turnBoundary }); + reporter.activateAcceptedBoundary({ readSnapshot: () => client.readSession(activeSessionId), describer: sessionDescriber }); + } catch { reporter.activateAcceptedBoundary({}); } + reporter.activate({ method: 'state.updated', params: { scope: 'session', sessionId: activeSessionId, reason: 'prompt_started' } }); await waitForCompletionOrAbort(client.waitForCompletion(activeSessionId), input.signal); await cleanupProgress(); const finalSnapshot = await client.readSession(activeSessionId); diff --git a/marketplace/plugins/zcode/scripts/lib/session-progress.mjs b/marketplace/plugins/zcode/scripts/lib/session-progress.mjs new file mode 100644 index 00000000..ef8f5f28 --- /dev/null +++ b/marketplace/plugins/zcode/scripts/lib/session-progress.mjs @@ -0,0 +1,113 @@ +import { fitProgressMessage, formatToolTerminalMessage } from './conversation-progress.mjs'; +import { isSafeIdentifier } from './identifier.mjs'; + +const START_STATUSES = new Set(['pending', 'running']); +const TERMINAL_STATUSES = new Set(['completed', 'error']); +const SAFE_TOOL_NAMES = new Set(['Bash', 'Read', 'Edit', 'Write', 'Grep', 'Glob', 'WebSearch']); +const MAX_TRACKED_CALLS = 256; +const MAX_SNAPSHOT_MESSAGES = 4_096; + +/** + * Describe only schema-validated session snapshots for the accepted turn. + * @param {{workspace:string,turnBoundary:{inputId:string,stateRevision:number,beforeMessageIds:Set}}} options + */ +export async function createSessionProgressDescriber({ turnBoundary }) { + const beforeMessageIds = turnBoundary.beforeMessageIds; + const inputId = turnBoundary.inputId; + const boundaryRevision = turnBoundary.stateRevision; + const calls = new Map(); + + return { + /** @param {any} snapshot @param {string} observedAt */ + async observe(snapshot, observedAt) { + if (!validTimestamp(observedAt) || !Number.isSafeInteger(snapshot?.runtime?.stateRevision) + || snapshot.runtime.stateRevision < boundaryRevision || !Array.isArray(snapshot?.messages) + || snapshot.messages.length > MAX_SNAPSHOT_MESSAGES || hasUnsafeOrDuplicateMessageRelationships(snapshot.messages)) return []; + const root = selectRoot(snapshot.messages, inputId, beforeMessageIds); + if (!root) return []; + const assistants = snapshot.messages.filter((/** @type {any} */ message) => isLinkedAssistant(message, root.info.messageId, beforeMessageIds)); + const events = []; + for (const assistant of assistants) { + if (!Array.isArray(assistant.parts)) return []; + for (const part of assistant.parts) { + if (part?.type !== 'tool') continue; + const event = describePart(part, observedAt, calls); + if (event) events.push(event); + } + } + return events; + }, + }; +} + +/** @param {any[]} messages */ +function hasUnsafeOrDuplicateMessageRelationships(messages) { + const seen = new Set(); + for (const message of messages) { + const id = message?.info?.messageId; + if (!isSafeIdentifier(id) || message?.info?.role === 'assistant' && !isSafeIdentifier(message.info.parentMessageId) || seen.has(id)) return true; + seen.add(id); + } + return false; +} + +/** @param {any[]} messages @param {string} inputId @param {Set} beforeMessageIds */ +function selectRoot(messages, inputId, beforeMessageIds) { + const direct = messages.filter((message) => message?.info?.messageId === inputId); + if (direct.length === 1 && isVisibleUserRoot(direct[0], beforeMessageIds)) return direct[0]; + if (direct.length !== 0) return null; + const candidates = messages.filter((message) => isVisibleUserRoot(message, beforeMessageIds)); + return candidates.length === 1 ? candidates[0] : null; +} + +/** @param {any} message @param {Set} beforeMessageIds */ +function isVisibleUserRoot(message, beforeMessageIds) { + const info = message?.info; const semantics = info?.semantics; + return info?.role === 'user' && isSafeIdentifier(info.messageId) && !beforeMessageIds.has(info.messageId) + && info.synthetic !== true && info.visibility !== 'model-only' && info.source === undefined + && (semantics === undefined || semantics.origin === 'real_user' && semantics.kind === 'user_prompt' && semantics.uiVisibility === 'visible'); +} + +/** @param {any} message @param {string} rootId @param {Set} beforeMessageIds */ +function isLinkedAssistant(message, rootId, beforeMessageIds) { + const info = message?.info; const semantics = info?.semantics; + return info?.role === 'assistant' && isSafeIdentifier(info.messageId) && !beforeMessageIds.has(info.messageId) + && isSafeIdentifier(info.parentMessageId) && info.parentMessageId === rootId && !['hidden', 'debug'].includes(semantics?.uiVisibility) + && (semantics === undefined || semantics.origin === 'agent_runtime' && semantics.kind === 'assistant_response'); +} + +/** @param {any} part @param {string} observedAt @param {Map} calls */ +function describePart(part, observedAt, calls) { + const callId = part.callId; const state = part.state; + if (!isSafeIdentifier(callId) || !state || typeof state !== 'object') return null; + const toolName = SAFE_TOOL_NAMES.has(part.tool) ? part.tool : ''; + const prior = calls.get(callId); + if (prior?.terminal) return null; + if (START_STATUSES.has(state.status)) { + if (prior?.started || !prior && calls.size >= MAX_TRACKED_CALLS) return null; + const message = fitProgressMessage(snapshotToolStartMessage(toolName)); + calls.set(callId, { started: true, terminal: false, message }); + return { phase: state.status === 'pending' ? 'waiting' : 'running', message, observedAt }; + } + if (!TERMINAL_STATUSES.has(state.status) || !prior && calls.size >= MAX_TRACKED_CALLS) return null; + const startMessage = prior?.message ?? fitProgressMessage(snapshotToolStartMessage(toolName)); + calls.set(callId, { started: prior?.started === true, terminal: true, message: startMessage }); + return { + phase: 'running', + message: fitProgressMessage(formatToolTerminalMessage( + { toolName, startedAt: state.startedAt, endedAt: state.completedAt }, + startMessage, + state.status === 'completed', + )), + observedAt, + }; +} + +/** @param {string} toolName */ +function snapshotToolStartMessage(toolName) { return toolName ? `Running tool: ${toolName}.` : 'Running a tool.'; } + +/** @param {unknown} value */ +function validTimestamp(value) { + if (typeof value !== 'string' || value.length === 0) return false; + try { return new Date(value).toISOString() === value; } catch { return false; } +} diff --git a/marketplace/plugins/zcode/scripts/lib/state.mjs b/marketplace/plugins/zcode/scripts/lib/state.mjs index a87ac187..43434374 100644 --- a/marketplace/plugins/zcode/scripts/lib/state.mjs +++ b/marketplace/plugins/zcode/scripts/lib/state.mjs @@ -13,6 +13,7 @@ import { } from './fs.mjs'; import { isSafeIdentifier } from './identifier.mjs'; import { + MAX_PROGRESS_PROBE_COUNT, MAX_PROGRESS_MESSAGE_BYTES, MAX_PROGRESS_PREVIEW_ENTRIES, PROGRESS_PHASES, @@ -193,6 +194,25 @@ export function createStateStore(options) { }); }, + /** @param {string} workspace @param {string} jobId @param {unknown} progressProbe */ + async updateJobProgressProbe(workspace, jobId, progressProbe) { + const boundedProbe = normalizeProgressProbeInput(workspace, jobId, progressProbe); + const storage = await jobStorage(dataRoot, workspace); + return withFileLock(storage.lockPath, async () => { + const path = jobPath(storage.jobsDirectory, jobId); + const job = await readJobRecord(path, jobId, storage.workspacePath); + if (job.status === 'queued' || TERMINAL_STATUSES.has(job.status)) return job; + const updated = { + ...job, + progressProbe: boundedProbe, + updatedAt: new Date(Math.max(Date.now(), Date.parse(job.updatedAt))).toISOString(), + }; + validateJobRecord(updated, jobId, storage.workspacePath); + await atomicWriteJson(path, updated); + return updated; + }); + }, + /** @param {string} workspace @param {string} jobId */ async readJob(workspace, jobId) { const storage = await jobStorage(dataRoot, workspace); @@ -679,6 +699,53 @@ function validateProgressInput(workspace, jobId, event) { if (invalidFields.length > 0) throw invalidProgressInput(invalidFields); } +const PROGRESS_PROBE_STATES = new Set(['probing', 'online', 'snapshot-fallback', 'lifecycle-only']); +const PROGRESS_PROBE_REJECTIONS = ['wire-version', 'envelope-shape', 'sequence', 'topic', 'row-kind', 'row-shape']; + +/** @param {unknown} value */ +export function validProgressProbe(value) { + if (!isPlainJsonObject(value) + || Object.keys(value).sort().join(',') !== 'acceptedInitial,acceptedOnline,acceptedRecovery,framesReceived,rejected,snapshotFallbackActive,snapshotFallbackUnavailable,state,subscriptionAcknowledged' + || !PROGRESS_PROBE_STATES.has(value.state) + || typeof value.subscriptionAcknowledged !== 'boolean' + || typeof value.snapshotFallbackActive !== 'boolean' + || typeof value.snapshotFallbackUnavailable !== 'boolean' + || !isPlainJsonObject(value.rejected) + || Object.keys(value.rejected).sort().join(',') !== [...PROGRESS_PROBE_REJECTIONS].sort().join(',')) return false; + for (const field of ['framesReceived', 'acceptedInitial', 'acceptedOnline', 'acceptedRecovery']) { + if (!boundedProbeCount(value[field])) return false; + } + for (const reason of PROGRESS_PROBE_REJECTIONS) if (!boundedProbeCount(value.rejected[reason])) return false; + if (value.state === 'snapshot-fallback') return value.snapshotFallbackActive && !value.snapshotFallbackUnavailable; + if (value.state === 'lifecycle-only') return !value.snapshotFallbackActive && value.snapshotFallbackUnavailable; + return !value.snapshotFallbackActive && !value.snapshotFallbackUnavailable; +} + +/** @param {unknown} value */ +function boundedProbeCount(value) { return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 && value <= MAX_PROGRESS_PROBE_COUNT; } + +/** @param {unknown} workspace @param {unknown} jobId @param {unknown} progressProbe */ +function normalizeProgressProbeInput(workspace, jobId, progressProbe) { + const invalid = () => new PluginError('JOB_PROGRESS_PROBE_INPUT_INVALID', 'Job progress compatibility probe is invalid.', { + category: 'state', remedy: 'Provide only the fixed bounded progress compatibility schema.', + }); + if (!isNonEmptyString(workspace) || !isDigest(jobId) || !isPlainJsonObject(progressProbe) + || Object.keys(progressProbe).sort().join(',') !== 'acceptedInitial,acceptedOnline,acceptedRecovery,framesReceived,rejected,snapshotFallbackActive,snapshotFallbackUnavailable,state,subscriptionAcknowledged' + || !isPlainJsonObject(progressProbe.rejected) + || Object.keys(progressProbe.rejected).sort().join(',') !== [...PROGRESS_PROBE_REJECTIONS].sort().join(',')) throw invalid(); + const normalized = /** @type {Record} */ ({ ...progressProbe, rejected: { ...progressProbe.rejected } }); + for (const field of ['framesReceived', 'acceptedInitial', 'acceptedOnline', 'acceptedRecovery']) { + if (!Number.isSafeInteger(normalized[field]) || normalized[field] < 0) throw invalid(); + normalized[field] = Math.min(MAX_PROGRESS_PROBE_COUNT, normalized[field]); + } + for (const reason of PROGRESS_PROBE_REJECTIONS) { + if (!Number.isSafeInteger(normalized.rejected[reason]) || normalized.rejected[reason] < 0) throw invalid(); + normalized.rejected[reason] = Math.min(MAX_PROGRESS_PROBE_COUNT, normalized.rejected[reason]); + } + if (!validProgressProbe(normalized)) throw invalid(); + return normalized; +} + /** @param {string[]} invalidFields */ function invalidProgressInput(invalidFields) { return new PluginError('JOB_PROGRESS_INPUT_INVALID', 'Job progress input is invalid.', { @@ -716,7 +783,8 @@ function validateJobRecord(job, expectedJobId, expectedWorkspacePath) { && (!('lastCancelError' in job) || isCancellationError(job.lastCancelError)) && (!('phase' in job) || PROGRESS_PHASES.includes(job.phase)) && (!('lastActivityAt' in job) || isIsoTimestamp(job.lastActivityAt)) - && (!('progressPreview' in job) || validProgressPreview(job.progressPreview)); + && (!('progressPreview' in job) || validProgressPreview(job.progressPreview)) + && (!('progressProbe' in job) || validProgressProbe(job.progressProbe)); const boundaryFields = ['inputId', 'startRevision', 'beforeMessageIds']; const hasBoundary = boundaryFields.some((field) => field in job); const validBoundary = !hasBoundary || boundaryFields.every((field) => field in job) diff --git a/marketplace/plugins/zcode/scripts/zcode-companion.mjs b/marketplace/plugins/zcode/scripts/zcode-companion.mjs index dded17e4..c3f03643 100644 --- a/marketplace/plugins/zcode/scripts/zcode-companion.mjs +++ b/marketplace/plugins/zcode/scripts/zcode-companion.mjs @@ -22,7 +22,7 @@ import { executeJob, readResultArtifact } from './lib/review.mjs'; import { reconcileOwnedJobs, scavengeWritableJobs, withWorkerLease } from './lib/recovery.mjs'; import { errorEnvelope, renderOutput } from './lib/render.mjs'; import { createForegroundSignalController } from './lib/signals.mjs'; -import { createStateStore } from './lib/state.mjs'; +import { createStateStore, validProgressProbe } from './lib/state.mjs'; import { resolveWorkspaceStorage } from './lib/workspace.mjs'; import { readWorkspaceModelConfig, summarizeWorkspaceModelConfig } from './lib/workspace-config.mjs'; import { executeTransfer, resolveTransferSource, TRANSFER_WIRE_LIMITS } from './lib/transfer.mjs'; @@ -71,7 +71,7 @@ export async function runCompanion(argv, runtime = {}) { if (parsed.options.all) return { jobs: (await store.listJobs(cwd)).map((job) => publicJob(job, caller.sessionId)), modelPolicy }; let job = await controller.selectOwned(cwd, caller.sessionId, parsed.positionals[0]); if (parsed.options.wait) job = await controller.wait(cwd, job.id, parsed.options.timeoutMs, runtime.signal); - return { job, modelPolicy }; + return { job: publicJob(job, caller.sessionId, true), modelPolicy }; } if (parsed.command === 'result') { const job = await controller.selectOwned(cwd, caller.sessionId, parsed.positionals[0], 'result'); @@ -278,8 +278,8 @@ function requireAuthorization(value, keys) { return value; } function authorizationInputError() { return new PluginError('INTERNAL_AUTHORIZATION_INVALID', 'The internal authorization envelope is invalid.', { category: 'authorization', remedy: 'Invoke this command through its installed skill using the protected internal channel.' }); } -/** @param {any} job @param {string} ownerSessionId */ -function publicJob(job, ownerSessionId) { +/** @param {any} job @param {string} ownerSessionId @param {boolean} [includeProgressProbe] */ +function publicJob(job, ownerSessionId, includeProgressProbe = false) { if (job.ownerSessionId !== ownerSessionId) { return { id: job.id, @@ -289,7 +289,8 @@ function publicJob(job, ownerSessionId) { hasOwner: true, }; } - const visible = { ...job }; delete visible.ownerSessionId; delete visible.ownerTurnId; delete visible.permissionSnapshot; + const visible = { ...job }; delete visible.ownerSessionId; delete visible.ownerTurnId; delete visible.permissionSnapshot; delete visible.progressProbe; + if (includeProgressProbe && validProgressProbe(job.progressProbe)) visible.progressProbe = { ...job.progressProbe, rejected: { ...job.progressProbe.rejected } }; return { ...visible, owned: true, owner: 'same-owner' }; } /** @param {Record} source @param {string[]} fields */ diff --git a/marketplace/plugins/zcode/skills/rescue/SKILL.md b/marketplace/plugins/zcode/skills/rescue/SKILL.md index 9268d8e6..6508f886 100644 --- a/marketplace/plugins/zcode/skills/rescue/SKILL.md +++ b/marketplace/plugins/zcode/skills/rescue/SKILL.md @@ -49,7 +49,8 @@ For the generic route, substitute only the preflight-verified absolute canonical ```text Act only as the installed ZCode Rescue forwarder. In the current workspace run exactly: node "/scripts/zcode-companion.mjs" invoke rescue -Preserve stderr and return public stdout verbatim. Do not inspect or modify code independently, interpret results, retry, poll, cancel, choose a pending branch, or request/print/persist authorization material. +Preserve stderr and return public stdout verbatim. Do not inspect or modify code independently, interpret results, retry, cancel, choose a pending branch, or request/print/persist authorization material. +Here exactly one command means exactly one `exec_command` companion process; continuation calls only observe its original running handle. Never start a second `exec_command`. A companion result containing an exit code is terminal. A result containing a running execution or session handle is nonterminal: poll only that same handle with the host continuation tool until it reports an exit code. Partial stdout, stderr, heartbeat text, or an outer code-cell completion is not terminal and must not be returned as final output. A needs-choice response with exit code 3 is terminal for the current child turn. If that command returned a needs-choice response, stop. Only after the parent sends exactly `Continue the pending ZCode Rescue with resume. Run only the installed resume forwarder command and return its public stdout verbatim.` run exactly: node "/scripts/zcode-companion.mjs" invoke-choice rescue resume Only after the parent sends exactly `Continue the pending ZCode Rescue with fresh. Run only the installed fresh forwarder command and return its public stdout verbatim.` run exactly: diff --git a/scripts/lib/conversation-progress.mjs b/scripts/lib/conversation-progress.mjs index b473df54..8f0798e9 100644 --- a/scripts/lib/conversation-progress.mjs +++ b/scripts/lib/conversation-progress.mjs @@ -16,6 +16,14 @@ const MAX_TRACKED_ROWS = 256; const MAX_PENDING_OBSERVATIONS = 4; const PATH_RESOLUTION_TIMEOUT_MS = 100; const CONVERSATION_WIRE_VERSION = 3; +/** @typedef {{phase:string,message:string,observedAt:string}} PublicProgressEvent */ +/** @typedef {{disposition:'accepted',phase:'initial'|'online'|'recovery',events:PublicProgressEvent[]}|{disposition:'rejected'|'ignored',reason:string,events:PublicProgressEvent[]}} ObservationResult */ +/** @param {string} reason @returns {ObservationResult} */ +const rejected = (reason) => ({ disposition: 'rejected', reason, events: [] }); +/** @param {string} reason @returns {ObservationResult} */ +const ignored = (reason) => ({ disposition: 'ignored', reason, events: [] }); +/** @param {'initial'|'online'|'recovery'} phase @param {PublicProgressEvent[]} [events] @returns {ObservationResult} */ +const accepted = (phase, events = []) => ({ disposition: 'accepted', phase, events }); /** @param {unknown} value @param {number} [limit] */ export function normalizePreview(value, limit = PREVIEW_LIMIT) { @@ -31,7 +39,7 @@ export function normalizePreview(value, limit = PREVIEW_LIMIT) { /** * @param {{sessionId:string,subscriptionId:string,workspace:string}} options * @param {{resolvePath?:(value:unknown,workspaceRoot:string)=>Promise,pathTimeoutMs?:number}} [dependencies] - * @returns {Promise<{observe:(notification:unknown,observedAt:string)=>Promise>,markGap:()=>void,markTerminal:()=>void}>} + * @returns {Promise<{observe:(notification:unknown,observedAt:string)=>Promise,markGap:()=>void,markTerminal:()=>void}>} */ export async function createConversationProgressDescriber({ sessionId, subscriptionId, workspace }, dependencies = {}) { const workspaceRoot = await realpath(resolve(workspace)); @@ -41,7 +49,7 @@ export async function createConversationProgressDescriber({ sessionId, subscript const pathTimeoutMs = Number.isSafeInteger(requestedPathTimeout) && /** @type {number} */ (requestedPathTimeout) >= 1 ? /** @type {number} */ (requestedPathTimeout) : PATH_RESOLUTION_TIMEOUT_MS; const toolStates = new Map(); const rowStates = new Map(); - /** @type {Array<{notification:unknown,observedAt:string,resolve:(events:any[])=>void}>} */ + /** @type {Array<{notification:unknown,observedAt:string,resolve:(result:ObservationResult)=>void}>} */ const pending = []; let active = false; /** @type {number|undefined} */ @@ -50,12 +58,12 @@ export async function createConversationProgressDescriber({ sessionId, subscript let lastSeq; let terminal = false; let needsRecovery = false; - /** @type {{observe:(notification:unknown,observedAt:string)=>Promise>,markGap:()=>void,markTerminal:()=>void}} */ + /** @type {{observe:(notification:unknown,observedAt:string)=>Promise,markGap:()=>void,markTerminal:()=>void}} */ const api = { observe(notification, observedAt) { - if (terminal) return Promise.resolve([]); + if (terminal) return Promise.resolve(ignored('terminal')); return new Promise((resolveResult) => { - if (active && pending.length >= MAX_PENDING_OBSERVATIONS) { markGap(); resolveResult([]); return; } + if (active && pending.length >= MAX_PENDING_OBSERVATIONS) { markGap(); resolveResult(ignored('overflow')); return; } pending.push({ notification, observedAt, resolve: resolveResult }); drain(); }); @@ -66,51 +74,61 @@ export async function createConversationProgressDescriber({ sessionId, subscript return api; function markGap() { if (!terminal) needsRecovery = true; } - function latchTerminal() { terminal = true; while (pending.length > 0) pending.shift()?.resolve([]); } + function latchTerminal() { terminal = true; while (pending.length > 0) pending.shift()?.resolve(ignored('terminal')); } function drain() { if (active || pending.length === 0) return; const item = pending.shift(); if (!item) return; active = true; - Promise.resolve().then(() => observeFrame(item.notification, item.observedAt)).catch(() => []).then((events) => item.resolve(events)).finally(() => { active = false; drain(); }); + Promise.resolve().then(() => observeFrame(item.notification, item.observedAt)).catch(() => rejected('row-shape')).then((result) => item.resolve(result)).finally(() => { active = false; drain(); }); } - /** @param {unknown} notification @param {unknown} observedAt */ + /** @param {unknown} notification @param {unknown} observedAt @returns {Promise} */ async function observeFrame(notification, observedAt) { - if (terminal || !validObservedAt(observedAt)) return []; + if (terminal) return ignored('terminal'); + if (!validObservedAt(observedAt)) return rejected('envelope-shape'); + const publicObservedAt = /** @type {string} */ (observedAt); const validated = validateNotification(notification, topic, subscriptionId); - if (!validated || validated.deliveryKind === 'initial') return []; - if (validated.deliveryKind === 'recovery') { - if (lastOrdinal !== undefined && (validated.ordinal <= lastOrdinal || validated.toSeq <= /** @type {number} */ (lastSeq))) return []; - if (!needsRecovery && lastOrdinal !== undefined && (validated.ordinal !== lastOrdinal + 1 || validated.fromSeq !== /** @type {number} */ (lastSeq) + 1)) return []; - lastOrdinal = validated.ordinal; lastSeq = validated.toSeq; needsRecovery = false; - absorbRecovery(validated.deltas); return []; + if (!validated.ok) return rejected(validated.reason); + const frame = validated.value; + if (frame.deliveryKind === 'initial') return accepted('initial'); + if (frame.deliveryKind === 'recovery') { + if (lastOrdinal !== undefined && (frame.ordinal <= lastOrdinal || frame.toSeq <= /** @type {number} */ (lastSeq))) { + needsRecovery = true; return rejected('sequence'); + } + if (!needsRecovery && lastOrdinal !== undefined && (frame.ordinal !== lastOrdinal + 1 || frame.fromSeq !== /** @type {number} */ (lastSeq) + 1)) { + needsRecovery = true; return rejected('sequence'); + } + lastOrdinal = frame.ordinal; lastSeq = frame.toSeq; needsRecovery = false; + absorbRecovery(frame.deltas); return accepted('recovery'); + } + if (lastOrdinal !== undefined && (frame.ordinal !== lastOrdinal + 1 || frame.fromSeq !== /** @type {number} */ (lastSeq) + 1)) { + needsRecovery = true; return rejected('sequence'); } - if (needsRecovery) return []; - if (lastOrdinal !== undefined && (validated.ordinal !== lastOrdinal + 1 || validated.fromSeq !== /** @type {number} */ (lastSeq) + 1)) return []; + if (needsRecovery) return ignored('recovery-required'); const staged = []; - for (const delta of validated.deltas) { + for (const delta of frame.deltas) { if (!delta.row) continue; if (delta.row.kind === 'toolCall') { - const event = await describeTool(delta.row, toolStates, workspaceRoot, /** @type {string} */ (observedAt), resolvePath, pathTimeoutMs, () => terminal || needsRecovery); - if (terminal || needsRecovery) return []; + const event = await describeTool(delta.row, toolStates, workspaceRoot, publicObservedAt, resolvePath, pathTimeoutMs, () => terminal || needsRecovery); + if (terminal || needsRecovery) return ignored(terminal ? 'terminal' : 'recovery-required'); if (event) staged.push(event); } else { const row = delta.row; const previous = rowStates.get(row.rowId); if (row.state === 'completedSuccess' || row.state === 'failed' || row.state === 'completedInterrupted') { if (previous !== undefined || rowStates.size < MAX_TRACKED_ROWS) rowStates.set(row.rowId, row.state); - staged.push({ phase: 'finalizing', message: row.state === 'completedSuccess' ? 'ZCode turn completed.' : 'ZCode turn ended without success.', observedAt }); + staged.push({ phase: 'finalizing', message: row.state === 'completedSuccess' ? 'ZCode turn completed.' : 'ZCode turn ended without success.', observedAt: publicObservedAt }); latchTerminal(); break; } if (previous === undefined && rowStates.size >= MAX_TRACKED_ROWS) continue; rowStates.set(row.rowId, row.state); if (previous === row.state) continue; - if (row.state === 'running' && previous === undefined) staged.push({ phase: 'starting', message: 'ZCode turn started.', observedAt }); + if (row.state === 'running' && previous === undefined) staged.push({ phase: 'starting', message: 'ZCode turn started.', observedAt: publicObservedAt }); } } - lastOrdinal = validated.ordinal; lastSeq = validated.toSeq; - return staged; + lastOrdinal = frame.ordinal; lastSeq = frame.toSeq; + return accepted('online', staged); } /** @param {Array<{op:string,row?:any}>} deltas */ @@ -141,14 +159,18 @@ export async function createConversationProgressDescriber({ sessionId, subscript */ export function createDeferredConversationProgressObserver({ sessionId, workspace }) { /** @type {Awaited>|undefined} */ let describer; - /** @type {Array<{notification:unknown,observedAt:string,resolve:(events:any[])=>void}>} */ const buffered = []; + /** @type {Array<{notification:unknown,observedAt:string,resolve:(result:ObservationResult)=>void}>} */ const buffered = []; let binding = false; let disabled = false; let terminal = false; let prebindGap = false; - const resolveBufferedEmpty = () => { while (buffered.length > 0) buffered.shift()?.resolve([]); }; - return /** @type {{observe:(notification:unknown,observedAt:string)=>Promise,bind:(subscriptionId:string)=>Promise,fail:()=>void,markGap:()=>void,markTerminal:()=>void}} */ ({ + /** @param {string} [reason] */ + const resolveBufferedEmpty = (reason = terminal ? 'terminal' : 'disabled') => { while (buffered.length > 0) buffered.shift()?.resolve(ignored(reason)); }; + return /** @type {{observe:(notification:unknown,observedAt:string)=>Promise,bind:(subscriptionId:string)=>Promise,fail:()=>void,markGap:()=>void,markTerminal:()=>void}} */ ({ observe(notification, observedAt) { - if (terminal || disabled) return Promise.resolve([]); + if (terminal || disabled) return Promise.resolve(ignored(terminal ? 'terminal' : 'disabled')); if (describer && !binding) return describer.observe(notification, observedAt); - if (buffered.length >= MAX_PENDING_OBSERVATIONS) return Promise.resolve([]); + if (buffered.length >= MAX_PENDING_OBSERVATIONS) { + if (describer) describer.markGap(); else prebindGap = true; + return Promise.resolve(ignored('overflow')); + } return new Promise((resolveResult) => buffered.push({ notification, observedAt, resolve: resolveResult })); }, async bind(subscriptionId) { @@ -156,11 +178,11 @@ export function createDeferredConversationProgressObserver({ sessionId, workspac binding = true; try { describer = await createConversationProgressDescriber({ sessionId, subscriptionId, workspace }); - if (prebindGap) describer.markGap(); while (!terminal && !disabled && buffered.length > 0) { const item = buffered.shift(); if (!item) break; item.resolve(await describer.observe(item.notification, item.observedAt)); } + if (prebindGap) describer.markGap(); } catch (error) { disabled = true; resolveBufferedEmpty(); throw error; } finally { binding = false; if (terminal || disabled) resolveBufferedEmpty(); } @@ -171,43 +193,46 @@ export function createDeferredConversationProgressObserver({ sessionId, workspac if (describer) describer.markGap(); else if (prebindGap) return; else prebindGap = true; - resolveBufferedEmpty(); + resolveBufferedEmpty('recovery-required'); }, markTerminal() { terminal = true; describer?.markTerminal(); resolveBufferedEmpty(); }, }); } -/** @param {unknown} notification @param {string} topic @param {string} subscriptionId @returns {{deliveryKind:string,ordinal:number,fromSeq:number,toSeq:number,deltas:Array<{op:string,row?:any}>}|null} */ +/** @param {unknown} notification @param {string} topic @param {string} subscriptionId @returns {{ok:true,value:{deliveryKind:'initial'|'online'|'recovery',ordinal:number,fromSeq:number,toSeq:number,deltas:Array<{op:string,row:Record}>}}|{ok:false,reason:string}} */ function validateNotification(notification, topic, subscriptionId) { - if (!plainObject(notification) || notification.method !== 'v4/conversation/frame' || !plainObject(notification.params)) return null; + if (!plainObject(notification) || notification.method !== 'v4/conversation/frame' || !plainObject(notification.params)) return { ok: false, reason: 'envelope-shape' }; const wire = notification.params; - if (!exactKeys(wire, ['wireVersion', 'kind', 'deliveryKind', 'logicalFrameId', 'logicalFrameOrdinal', 'topic', 'subscriptionId', 'frame']) - || wire.wireVersion !== CONVERSATION_WIRE_VERSION || wire.kind !== 'complete' + if (!exactKeys(wire, ['wireVersion', 'kind', 'deliveryKind', 'logicalFrameId', 'logicalFrameOrdinal', 'topic', 'subscriptionId', 'frame'])) return { ok: false, reason: 'envelope-shape' }; + if (wire.wireVersion !== CONVERSATION_WIRE_VERSION) return { ok: false, reason: 'wire-version' }; + if (wire.topic !== topic || wire.subscriptionId !== subscriptionId) return { ok: false, reason: 'topic' }; + if (wire.kind !== 'complete' || !['initial', 'online', 'recovery'].includes(wire.deliveryKind) - || !boundedIdentifier(wire.logicalFrameId, 256) || !positiveInteger(wire.logicalFrameOrdinal) - || wire.topic !== topic || wire.subscriptionId !== subscriptionId || !plainObject(wire.frame)) return null; + || !boundedIdentifier(wire.logicalFrameId, 256) || !plainObject(wire.frame)) return { ok: false, reason: 'envelope-shape' }; + if (!positiveInteger(wire.logicalFrameOrdinal)) return { ok: false, reason: 'sequence' }; const frame = wire.frame; - if (!exactKeys(frame, ['topic', 'subscriptionId', 'fromSeq', 'toSeq', 'sentAt', 'payload']) - || frame.topic !== topic || frame.subscriptionId !== subscriptionId - || !nonnegativeInteger(frame.fromSeq) || !nonnegativeInteger(frame.toSeq) || frame.toSeq < frame.fromSeq - || !wireTimestamp(frame.sentAt) || !plainObject(frame.payload) + if (!exactKeys(frame, ['topic', 'subscriptionId', 'fromSeq', 'toSeq', 'sentAt', 'payload'])) return { ok: false, reason: 'envelope-shape' }; + if (frame.topic !== topic || frame.subscriptionId !== subscriptionId) return { ok: false, reason: 'topic' }; + if (!nonnegativeInteger(frame.fromSeq) || !nonnegativeInteger(frame.toSeq) || frame.toSeq < frame.fromSeq) return { ok: false, reason: 'sequence' }; + if (!wireTimestamp(frame.sentAt) || !plainObject(frame.payload) || !exactKeys(frame.payload, ['kind', 'deltas']) || frame.payload.kind !== 'deltas' - || !Array.isArray(frame.payload.deltas) || frame.payload.deltas.length > MAX_DELTAS_PER_FRAME) return null; + || !Array.isArray(frame.payload.deltas) || frame.payload.deltas.length > MAX_DELTAS_PER_FRAME) return { ok: false, reason: 'envelope-shape' }; const deltas = []; for (const value of frame.payload.deltas) { - const delta = validateDelta(value); if (!delta) return null; deltas.push(delta); + const delta = validateDelta(value); if (!delta.ok) return delta; deltas.push(delta.value); } - return { deliveryKind: wire.deliveryKind, ordinal: wire.logicalFrameOrdinal, fromSeq: frame.fromSeq, toSeq: frame.toSeq, deltas }; + return { ok: true, value: { deliveryKind: wire.deliveryKind, ordinal: wire.logicalFrameOrdinal, fromSeq: frame.fromSeq, toSeq: frame.toSeq, deltas } }; } -/** @param {unknown} value */ +/** @param {unknown} value @returns {{ok:true,value:{op:string,row:Record}}|{ok:false,reason:string}} */ function validateDelta(value) { - if (!plainObject(value) || typeof value.op !== 'string') return null; + if (!plainObject(value) || typeof value.op !== 'string') return { ok: false, reason: 'row-shape' }; if (value.op === 'row.appended' || value.op === 'row.upserted') { - if (!exactKeys(value, ['op', 'row']) || !plainObject(value.row)) return null; - const row = validateRow(value.row); return row ? { op: value.op, row } : null; + if (!exactKeys(value, ['op', 'row']) || !plainObject(value.row)) return { ok: false, reason: 'row-shape' }; + if (!['toolCall', 'turnHeader'].includes(value.row.kind)) return { ok: false, reason: 'row-kind' }; + const row = validateRow(value.row); return row ? { ok: true, value: { op: value.op, row } } : { ok: false, reason: 'row-shape' }; } - return null; + return { ok: false, reason: 'row-kind' }; } /** @param {Record} row */ @@ -250,33 +275,39 @@ async function describeTool(row, states, workspaceRoot, observedAt, resolvePath, const status = row.status; if (START_STATUSES.has(status)) { if (prior.started || !states.has(key) && states.size >= MAX_TRACKED_ROWS) return null; - const message = fitProgressMessage(await startMessage(row, workspaceRoot, resolvePath, timeoutMs)); + const message = fitProgressMessage(await formatToolStartMessage(row, workspaceRoot, resolvePath, timeoutMs)); if (isTerminal()) return null; states.set(key, { started: true, terminal: false, message }); return { phase: status === 'pendingApproval' ? 'waiting' : 'running', message, observedAt }; } if (!SUCCESS_STATUSES.has(status) && !FAILURE_STATUSES.has(status)) return null; if (!states.has(key) && states.size >= MAX_TRACKED_ROWS) return null; - const startMessageValue = prior.message ?? await startMessage(row, workspaceRoot, resolvePath, timeoutMs); + const startMessageValue = prior.message ?? await formatToolStartMessage(row, workspaceRoot, resolvePath, timeoutMs); if (isTerminal()) return null; states.set(key, { started: prior.started, terminal: true, message: startMessageValue }); const duration = durationSuffix(row.startedAt, row.endedAt); - return { phase: 'running', message: fitProgressMessage(terminalMessage(row, startMessageValue, SUCCESS_STATUSES.has(status), duration)), observedAt }; + return { phase: 'running', message: fitProgressMessage(formatToolTerminalMessage(row, startMessageValue, SUCCESS_STATUSES.has(status), duration)), observedAt }; } /** @param {any} row @param {string} workspaceRoot @param {(value:unknown,root:string)=>Promise} resolvePath @param {number} timeoutMs */ -async function startMessage(row, workspaceRoot, resolvePath, timeoutMs) { +export async function formatToolStartMessage(row, workspaceRoot, resolvePath = containedRelativePath, timeoutMs = PATH_RESOLUTION_TIMEOUT_MS, allowTextPreviews = true) { + return formatToolStartMessageWithOptions(row, workspaceRoot, { resolvePath, timeoutMs, allowTextPreviews }); +} + +/** @param {any} row @param {string} workspaceRoot @param {{resolvePath:(value:unknown,root:string)=>Promise,timeoutMs:number,allowTextPreviews:boolean}} options */ +async function formatToolStartMessageWithOptions(row, workspaceRoot, { resolvePath, timeoutMs, allowTextPreviews }) { const toolName = normalizePreview(row.toolName, 64); const input = plainObject(row.input) ? row.input : {}; - if (toolName === 'Bash') { const preview = normalizePreview(input.command); return preview ? `Running command: ${preview}.` : 'Running tool: Bash.'; } + if (!toolName) return 'Running a tool.'; + if (toolName === 'Bash') { const preview = allowTextPreviews ? normalizePreview(input.command) : ''; return preview ? `Running command: ${preview}.` : 'Running tool: Bash.'; } if (['Read', 'Edit', 'Write'].includes(toolName)) { const path = await boundedPath(resolvePath, input.file_path, workspaceRoot, timeoutMs); if (!path) return `Running tool: ${toolName}.`; return `${toolName === 'Read' ? 'Reading' : toolName === 'Edit' ? 'Editing' : 'Writing'}: ${path}.`; } - if (toolName === 'Grep') { const preview = normalizePreview(input.pattern); return preview ? `Searching files: ${preview}.` : 'Running tool: Grep.'; } - if (toolName === 'Glob') { const preview = normalizePreview(input.pattern); return preview ? `Finding files: ${preview}.` : 'Running tool: Glob.'; } - if (toolName === 'WebSearch') { const preview = normalizePreview(input.query); return preview ? `Searching the web: ${preview}.` : 'Running tool: WebSearch.'; } + if (toolName === 'Grep') { const preview = allowTextPreviews ? normalizePreview(input.pattern) : ''; return preview ? `Searching files: ${preview}.` : 'Running tool: Grep.'; } + if (toolName === 'Glob') { const preview = allowTextPreviews ? normalizePreview(input.pattern) : ''; return preview ? `Finding files: ${preview}.` : 'Running tool: Glob.'; } + if (toolName === 'WebSearch') { const preview = allowTextPreviews ? normalizePreview(input.query) : ''; return preview ? `Searching the web: ${preview}.` : 'Running tool: WebSearch.'; } return `Running tool: ${toolName}.`; } @@ -290,13 +321,14 @@ async function boundedPath(resolvePath, value, root, timeoutMs) { } /** @param {any} row @param {string} started @param {boolean} succeeded @param {string} duration */ -function terminalMessage(row, started, succeeded, duration) { +export function formatToolTerminalMessage(row, started, succeeded, duration = durationSuffix(row.startedAt, row.endedAt)) { const state = succeeded ? 'completed' : 'failed'; if (started.startsWith('Running command: ')) { const value = started.slice(17); const command = value.endsWith('.') ? value.slice(0, -1) : value; return `Command ${state}: ${command}${duration}.`; } - return `${normalizePreview(row.toolName, 64)} ${state}${duration}.`; + const toolName = normalizePreview(row.toolName, 64); + return `${toolName || 'Tool'} ${state}${duration}.`; } /** @param {unknown} startedAt @param {unknown} endedAt */ @@ -307,7 +339,7 @@ function durationSuffix(startedAt, endedAt) { } /** @param {string} message */ -function fitProgressMessage(message) { +export function fitProgressMessage(message) { if (Buffer.byteLength(message) <= MAX_PUBLIC_MESSAGE_BYTES) return message; let output = ''; for (const character of message) { diff --git a/scripts/lib/progress.mjs b/scripts/lib/progress.mjs index d133fc7e..ecd930cb 100644 --- a/scripts/lib/progress.mjs +++ b/scripts/lib/progress.mjs @@ -4,6 +4,8 @@ export const MAX_PROGRESS_PENDING_EVENTS = 4; export const MAX_PROGRESS_MESSAGE_BYTES = 256; export const PROGRESS_HEARTBEAT_MS = 20_000; export const MAX_PROGRESS_DIAGNOSTIC_KINDS = 8; +export const MAX_PROGRESS_PROBE_COUNT = 255; +export const PROGRESS_PROBE_REJECTION_THRESHOLD = 4; const PROGRESS_FLUSH_TIMEOUT_MS = 250; const PROGRESS_SEMANTIC_GRACE_MS = 125; @@ -15,8 +17,12 @@ const PROGRESS_DIAGNOSTICS = new Map([ ['writer-disabled', 'ZCode progress output was disabled.'], ['preview-disabled', 'ZCode progress preview was disabled.'], ['progress-flush-timeout', 'ZCode progress cleanup reached its time limit.'], + ['conversation-snapshot-fallback', 'ZCode conversation frames were unavailable; using bounded session progress.'], + ['conversation-lifecycle-only', 'ZCode semantic progress is unavailable; lifecycle updates will continue.'], ]); +const PROBE_REJECTION_REASONS = Object.freeze(['wire-version', 'envelope-shape', 'sequence', 'topic', 'row-kind', 'row-shape']); + /** @template T @param {Promise} completion @param {AbortSignal|undefined} signal @returns {Promise} */ export async function waitForCompletionOrAbort(completion, signal) { const completionPromise = Promise.resolve(completion); @@ -58,13 +64,15 @@ export function normalizeZCodeProgress(notification, sessionId, observedAt) { } /** - * @param {{sessionId:string,deferred?:boolean,write?:(line:string)=>void,persist?:(event:{phase:string,message:string,observedAt:string})=>Promise|void,describeNotification?:(notification:unknown,observedAt:string)=>Array<{phase:string,message:string,observedAt:string}>|Promise>,onDescriptorOverflow?:()=>void,onDiagnostic?:(diagnostic:{kind:string})=>void,now?:()=>string,setInterval?:(callback:()=>void,milliseconds:number)=>any,clearInterval?:(timer:any)=>void}} options + * @param {{sessionId:string,deferred?:boolean,write?:(line:string)=>void,persist?:(event:{phase:string,message:string,observedAt:string})=>Promise|void,persistProbe?:(probe:any)=>Promise|void,activateSnapshotFallback?:()=>false|(()=>unknown),describeNotification?:(notification:unknown,observedAt:string)=>any|Promise,onDescriptorOverflow?:()=>void,onDiagnostic?:(diagnostic:{kind:string})=>void,now?:()=>string,setInterval?:(callback:()=>void,milliseconds:number)=>any,clearInterval?:(timer:any)=>void}} options */ export function createProgressReporter({ sessionId, deferred = false, write, persist, + persistProbe, + activateSnapshotFallback: configuredSnapshotFallback, describeNotification, onDescriptorOverflow, onDiagnostic, @@ -97,6 +105,51 @@ export function createProgressReporter({ let observationSequence = 0; /** @type {number|null} */ let terminalSequence = null; let terminalDispatched = false; + const progressProbe = { + state: 'probing', subscriptionAcknowledged: false, framesReceived: 0, + acceptedInitial: 0, acceptedOnline: 0, acceptedRecovery: 0, + rejected: Object.fromEntries(PROBE_REJECTION_REASONS.map((reason) => [reason, 0])), + snapshotFallbackActive: false, snapshotFallbackUnavailable: false, + }; + let compatibilityBoundaryActivated = false; + let acceptedBoundaryActivated = typeof configuredSnapshotFallback === 'function'; + /** @type {undefined|(()=>false|(()=>unknown))} */ let activateSnapshotFallback = configuredSnapshotFallback; + /** @type {null|(()=>unknown)} */ let snapshotFallbackCleanup = null; + /** @type {null|(()=>Promise)} */ let snapshotRead = null; + /** @type {null|{observe:(snapshot:unknown,observedAt:string)=>unknown|Promise}} */ let snapshotDescriber = null; + /** @type {Promise|null} */ let snapshotReadInFlight = null; + let snapshotEpoch = 0; + let adjacentHeartbeatClaimed = false; + const cleanupSnapshotFallback = () => { + const cleanup = snapshotFallbackCleanup; snapshotFallbackCleanup = null; + if (cleanup === null) return false; + try { Promise.resolve(cleanup()).catch(() => {}); } catch { /* fallback cleanup is observational */ } + return true; + }; + const probeSnapshot = () => ({ ...progressProbe, rejected: { ...progressProbe.rejected } }); + /** @type {Promise|null} */ let probePersistInFlight = null; + /** @type {any|null} */ let probePersistPending = null; + /** @param {any} snapshot */ + const startProbePersist = (snapshot) => { + let operation; + try { operation = Promise.resolve(/** @type {(probe:any)=>Promise|void} */ (persistProbe)(snapshot)); } + catch { operation = Promise.reject(new Error('progress probe persistence failed')); } + const tracked = operation.catch(() => {}).then(() => { + if (probePersistInFlight !== tracked) return; + probePersistInFlight = null; + if (closed || probePersistPending === null) { probePersistPending = null; return; } + const next = probePersistPending; probePersistPending = null; startProbePersist(next); + }); + probePersistInFlight = tracked; + }; + const persistProbeSnapshot = () => { + if (closed || typeof persistProbe !== 'function') return; + const snapshot = probeSnapshot(); + if (probePersistInFlight === null) startProbePersist(snapshot); + else probePersistPending = snapshot; + }; + /** @param {number} value */ + const saturatingIncrement = (value) => Math.min(MAX_PROGRESS_PROBE_COUNT, value + 1); const diagnosedKinds = new Set(); /** @param {string} kind */ const diagnose = (kind) => { @@ -117,18 +170,94 @@ export function createProgressReporter({ /** @type {any} */ let timer = null; const startTimer = () => { - if (timer !== null || typeof write !== 'function') return; + if (timer !== null || typeof write !== 'function' && typeof persistProbe !== 'function' && typeof activateSnapshotFallback !== 'function') return; timer = setIntervalFn(() => { + activateCompatibilityBoundary(true); + if (progressProbe.state === 'snapshot-fallback') { + if (adjacentHeartbeatClaimed) adjacentHeartbeatClaimed = false; + else startSnapshotRead(false); + } const currentTime = now(); if (!validTimestamp(currentTime) || !validTimestamp(lastActivityAt)) return; const elapsedMs = Date.parse(currentTime) - Date.parse(lastActivityAt); if (elapsedMs < PROGRESS_HEARTBEAT_MS) return; const seconds = Math.floor(elapsedMs / 1_000); - try { if (!writerDisabled) write(`[zcode] Still waiting for ZCode; last activity ${seconds}s ago.\n`); } + try { if (!writerDisabled && typeof write === 'function') write(`[zcode] Still waiting for ZCode; last activity ${seconds}s ago.\n`); } catch { writerDisabled = true; diagnose('writer-disabled'); } }, PROGRESS_HEARTBEAT_MS); timer?.unref?.(); }; + /** @param {boolean} requireAcceptedBoundary */ + const activateCompatibilityBoundary = (requireAcceptedBoundary = false) => { + if (closed || !accepting || requireAcceptedBoundary && !acceptedBoundaryActivated || compatibilityBoundaryActivated || progressProbe.state !== 'probing' || progressProbe.acceptedOnline > 0) return false; + compatibilityBoundaryActivated = true; + /** @type {unknown} */ let activation = false; + try { activation = typeof activateSnapshotFallback === 'function' ? activateSnapshotFallback() : false; } catch { activation = false; } + if (typeof activation === 'function') { + snapshotFallbackCleanup = /** @type {()=>unknown} */ (activation); + progressProbe.state = 'snapshot-fallback'; progressProbe.snapshotFallbackActive = true; + diagnose('conversation-snapshot-fallback'); + startSnapshotRead(); + } else { + try { if (activation !== null && typeof activation === 'object') Promise.resolve(activation).catch(() => {}); } + catch { /* fallback activation failures are observational */ } + progressProbe.state = 'lifecycle-only'; progressProbe.snapshotFallbackUnavailable = true; + diagnose('conversation-lifecycle-only'); + } + persistProbeSnapshot(); return true; + }; + /** @param {boolean} claimAdjacentHeartbeat */ + const startSnapshotRead = (claimAdjacentHeartbeat = true) => { + if (closed || !accepting || progressProbe.state !== 'snapshot-fallback' || snapshotReadInFlight !== null + || snapshotRead === null || snapshotDescriber === null) return false; + const epoch = snapshotEpoch; + const read = snapshotRead; const describer = snapshotDescriber; + let operation; + try { operation = Promise.resolve().then(() => read()); } + catch { operation = Promise.reject(new Error('snapshot progress read failed')); } + const tracked = operation.then(async (snapshot) => { + if (closed || !accepting || epoch !== snapshotEpoch || progressProbe.state !== 'snapshot-fallback') return; + const observedAt = now(); if (!validTimestamp(observedAt)) throw new Error('snapshot progress timestamp invalid'); + const events = await describer.observe(snapshot, observedAt); + if (closed || !accepting || epoch !== snapshotEpoch || progressProbe.state !== 'snapshot-fallback') return; + if (!Array.isArray(events)) throw new Error('snapshot progress description invalid'); + const boundedEvents = events.slice(0, MAX_PROGRESS_PENDING_EVENTS); + if (!boundedEvents.every(validPublicEvent)) throw new Error('snapshot progress event invalid'); + for (const event of boundedEvents) { + const sequence = observationSequence; observationSequence += 1; + enqueueLogical({ kind: 'event', event, sequence }); + } + }).catch(() => { + if (closed || !accepting || epoch !== snapshotEpoch || progressProbe.state !== 'snapshot-fallback') return; + cleanupSnapshotFallback(); + progressProbe.state = 'lifecycle-only'; progressProbe.snapshotFallbackActive = false; progressProbe.snapshotFallbackUnavailable = true; + diagnose('conversation-lifecycle-only'); persistProbeSnapshot(); + }).then(() => { if (snapshotReadInFlight === tracked) snapshotReadInFlight = null; }); + snapshotReadInFlight = tracked; + if (claimAdjacentHeartbeat) adjacentHeartbeatClaimed = true; + return true; + }; + /** @param {unknown} result @param {number} epoch */ + const recordDescriptionResult = (result, epoch) => { + if (!accepting || epoch !== descriptorEpoch) return []; + if (!plainObject(result) || !Array.isArray(result.events)) return []; + if (result.disposition === 'accepted' && ['initial', 'online', 'recovery'].includes(result.phase)) { + const field = result.phase === 'initial' ? 'acceptedInitial' : result.phase === 'online' ? 'acceptedOnline' : 'acceptedRecovery'; + progressProbe[field] = saturatingIncrement(progressProbe[field]); + if (result.phase === 'online') { + cleanupSnapshotFallback(); + progressProbe.state = 'online'; progressProbe.snapshotFallbackActive = false; progressProbe.snapshotFallbackUnavailable = false; + } + persistProbeSnapshot(); return result.events; + } + if (result.disposition === 'rejected' && PROBE_REJECTION_REASONS.includes(result.reason)) { + progressProbe.rejected[result.reason] = saturatingIncrement(progressProbe.rejected[result.reason]); + persistProbeSnapshot(); + const total = PROBE_REJECTION_REASONS.reduce((sum, reason) => sum + progressProbe.rejected[reason], 0); + if (total >= PROGRESS_PROBE_REJECTION_THRESHOLD) activateCompatibilityBoundary(true); + } + return []; + }; /** @param {{event:{phase:string,message:string,observedAt:string},sequence:number}} entry */ const startWriter = (entry) => { if (typeof write !== 'function' || writerDisabled) return; @@ -227,11 +356,16 @@ export function createProgressReporter({ let described; try { described = Promise.resolve(describeNotification(item.notification, item.observedAt)); } catch { diagnose('conversation-render-failed'); described = Promise.resolve([]); } - descriptorInFlight = described.then((events) => { - if (epoch !== descriptorEpoch || !Array.isArray(events) || closed) return; - item.events = events.slice(0, MAX_PROGRESS_PENDING_EVENTS).filter(validPublicEvent); + const tracked = described.then((description) => { + if (closed) return; + const current = accepting && epoch === descriptorEpoch; + const events = current + ? Array.isArray(description) ? description : recordDescriptionResult(description, epoch) + : Array.isArray(description) ? description : plainObject(description) && description.disposition === 'accepted' && Array.isArray(description.events) ? description.events : []; + if (!Array.isArray(events)) return; + item.events = events.slice(0, MAX_PROGRESS_PENDING_EVENTS).filter((event) => validPublicEvent(event) && (current || event.phase !== 'finalizing')); for (const describedEvent of item.events) { - if (describedEvent.phase === 'finalizing') { + if (current && describedEvent.phase === 'finalizing') { terminalSequence = terminalSequence === null ? item.sequence : Math.min(terminalSequence, item.sequence); for (const later of logicalPending) if (later.sequence > terminalSequence) { if (later.kind === 'descriptor') later.state = 'dropped'; @@ -239,10 +373,15 @@ export function createProgressReporter({ } } item.state = 'ready'; - }).catch(() => { if (epoch === descriptorEpoch) { item.state = 'dropped'; diagnose('conversation-render-failed'); } }).then(() => { - if (epoch !== descriptorEpoch) return; - descriptorInFlight = null; activeDescriptor = null; pumpLogical(); + }).catch(() => { + item.state = 'dropped'; + if (accepting && epoch === descriptorEpoch) diagnose('conversation-render-failed'); + }).then(() => { + if (descriptorInFlight === tracked) descriptorInFlight = null; + if (activeDescriptor === item) activeDescriptor = null; + pumpLogical(); }); + descriptorInFlight = tracked; }; /** @param {{notification:unknown,observedAt:string,sequence:number}} item */ const enqueueDescribe = (item) => { @@ -282,6 +421,35 @@ export function createProgressReporter({ if (active) startTimer(); return { + markConversationSubscribed() { + if (closed || !accepting || progressProbe.subscriptionAcknowledged) return false; + progressProbe.subscriptionAcknowledged = true; persistProbeSnapshot(); return true; + }, + activateCompatibilityBoundary: () => activateCompatibilityBoundary(false), + /** @param {{readSnapshot:()=>Promise,describer:{observe:(snapshot:unknown,observedAt:string)=>unknown|Promise}}} boundary */ + activateAcceptedBoundary(boundary) { + if (closed || acceptedBoundaryActivated) return false; + acceptedBoundaryActivated = true; + if (typeof boundary?.readSnapshot === 'function' && typeof boundary?.describer?.observe === 'function') { + snapshotRead = boundary.readSnapshot; snapshotDescriber = boundary.describer; + activateSnapshotFallback = () => { + if (closed || snapshotRead === null || snapshotDescriber === null) return false; + const epoch = snapshotEpoch + 1; snapshotEpoch = epoch; + startSnapshotRead(); + let cleaned = false; + return () => { + if (cleaned) return; + cleaned = true; + if (snapshotEpoch === epoch) snapshotEpoch += 1; + snapshotReadInFlight = null; + }; + }; + } + const rejectedTotal = PROBE_REJECTION_REASONS.reduce((sum, reason) => sum + progressProbe.rejected[reason], 0); + if (rejectedTotal >= PROGRESS_PROBE_REJECTION_THRESHOLD) activateCompatibilityBoundary(true); + return true; + }, + probeSnapshot, /** @param {unknown} notification */ observe(notification) { if (closed || !accepting) return null; @@ -290,6 +458,7 @@ export function createProgressReporter({ const observedAt = now(); const event = normalizeZCodeProgress(notification, sessionId, observedAt); if (event === null && typeof describeNotification === 'function' && plainObject(notification) && notification.method === 'v4/conversation/frame') { + progressProbe.framesReceived = saturatingIncrement(progressProbe.framesReceived); persistProbeSnapshot(); enqueueDescribe({ notification, observedAt, sequence }); return null; } if (event === null) return null; @@ -301,6 +470,7 @@ export function createProgressReporter({ activate(initialNotification) { if (active || closed) return false; const activatedAt = now(); active = true; lastActivityAt = activatedAt; startTimer(); + persistProbeSnapshot(); const initial = normalizeZCodeProgress(initialNotification, sessionId, activatedAt); if (initial) dispatch(initial, -1); for (const { event, sequence } of buffered.sort((left, right) => left.sequence - right.sequence)) dispatch({ ...event, observedAt: activatedAt }, sequence); @@ -308,7 +478,12 @@ export function createProgressReporter({ }, /** @param {string} kind */ diagnose(kind) { return diagnose(kind); }, - stopAccepting() { accepting = false; }, + stopAccepting() { + if (!accepting) return; + accepting = false; descriptorEpoch += 1; + for (const item of logicalPending) if (item.kind === 'descriptor' && item.state === 'pending' && item !== activeDescriptor) item.state = 'dropped'; + descriptorOverflowed = false; pumpLogical(); + }, async flush(absoluteDeadline = Date.now() + PROGRESS_FLUSH_TIMEOUT_MS) { const deadline = Math.min(absoluteDeadline, Date.now() + PROGRESS_FLUSH_TIMEOUT_MS); await Promise.resolve(); @@ -321,23 +496,27 @@ export function createProgressReporter({ diagnose('progress-flush-timeout'); await Promise.resolve(); } const sinkBudget = remaining(deadline); - const [writerDrained, persistenceDrained] = await Promise.all([ - waitWithin(drainWriter(), sinkBudget), waitWithin(drainPersistence(), sinkBudget), + const [writerDrained, persistenceDrained, probePersistenceDrained] = await Promise.all([ + waitWithin(drainWriter(), sinkBudget), waitWithin(drainPersistence(), sinkBudget), waitWithin(drainProbePersistence(), sinkBudget), ]); - if (!writerDrained || !persistenceDrained) { + if (!writerDrained || !persistenceDrained || !probePersistenceDrained) { diagnose('progress-flush-timeout'); await Promise.resolve(); if (!writerDrained) disableWriter(); if (!persistenceDrained) disablePersist(); + if (!probePersistenceDrained) disableProbePersist(); const finalBudget = remaining(deadline); await Promise.all([ writerDisabled ? Promise.resolve() : waitWithin(drainWriter(), finalBudget), persistDisabled ? Promise.resolve() : waitWithin(drainPersistence(), finalBudget), + probePersistInFlight === null ? Promise.resolve() : waitWithin(drainProbePersistence(), finalBudget), ]); } return true; }, close() { + cleanupSnapshotFallback(); accepting = false; closed = true; buffered.length = 0; bufferedKeys.clear(); + disableProbePersist(); descriptorEpoch += 1; for (const item of logicalPending) if (item.kind === 'descriptor') item.state = 'dropped'; activeDescriptor = null; descriptorInFlight = null; pumpLogical(); logicalPending.length = 0; @@ -367,10 +546,15 @@ export function createProgressReporter({ while (persistInFlight !== null) await persistInFlight; } + async function drainProbePersistence() { + while (probePersistInFlight !== null) await probePersistInFlight; + } + async function drainWriter() { while (writerInFlight !== null) await writerInFlight; } function disableWriter() { writerEpoch += 1; writerDisabled = true; writerInFlight = null; writerPending.length = 0; } function disablePersist() { persistEpoch += 1; persistDisabled = true; persistInFlight = null; persistPending.length = 0; } + function disableProbePersist() { probePersistInFlight = null; probePersistPending = null; } } /** @param {Promise} operation @param {number} milliseconds */ diff --git a/scripts/lib/render.mjs b/scripts/lib/render.mjs index 0a7b9faf..e7620b74 100644 --- a/scripts/lib/render.mjs +++ b/scripts/lib/render.mjs @@ -1,4 +1,5 @@ import { PluginError } from './errors.mjs'; +import { validProgressProbe } from './state.mjs'; /** @param {unknown} error */ export function errorEnvelope(error) { @@ -8,7 +9,7 @@ export function errorEnvelope(error) { /** @param {any} value @param {{json?:boolean}} [options] */ export function renderOutput(value, options = {}) { - if (options.json) return `${JSON.stringify(redact(value))}\n`; + if (options.json) return `${JSON.stringify(redact(value, exactOwnerJob(value)))}\n`; if (value?.type === 'transfer' && typeof value.result === 'string') return value.result.endsWith('\n') ? value.result : `${value.result}\n`; if (value?.type === 'background') return `Reserved background job ${value.job.id}.\n`; if (value?.jobs) return `${value.jobs.map(renderCompactJob).join('\n')}\n${renderModelPolicy(value.modelPolicy)}`; @@ -143,14 +144,23 @@ function renderModelPolicy(policy) { return policy ? `Model policy: default=${po /** Internal machine transport. Never use for user-facing rendering. @param {unknown} value */ export function renderInternalOutput(value) { return `${JSON.stringify(value)}\n`; } -/** @param {any} value @returns {any} */ -function redact(value) { - if (Array.isArray(value)) return value.map(redact); +/** @param {any} value @returns {any|null} */ +function exactOwnerJob(value) { + return value && typeof value === 'object' && !Array.isArray(value) && !Object.hasOwn(value, 'jobs') + && value.job && typeof value.job === 'object' && !Array.isArray(value.job) + && value.job.owned === true && value.job.owner === 'same-owner' && validProgressProbe(value.job.progressProbe) + ? value.job : null; +} + +/** @param {any} value @param {any|null} [progressProbeOwner] @returns {any} */ +function redact(value, progressProbeOwner = null) { + if (Array.isArray(value)) return value.map((entry) => redact(entry, progressProbeOwner)); if (!value || typeof value !== 'object') return value; /** @type {Record} */ const result = {}; for (const [key, entry] of Object.entries(value)) { if (/token|capability|permissionSnapshot|privateInvocation/i.test(key)) continue; - result[key] = redact(entry); + if (/progressProbe/i.test(key) && (value !== progressProbeOwner || key !== 'progressProbe' || !validProgressProbe(entry))) continue; + result[key] = redact(entry, progressProbeOwner); } return result; } diff --git a/scripts/lib/review.mjs b/scripts/lib/review.mjs index 5b5e5830..41f930c0 100644 --- a/scripts/lib/review.mjs +++ b/scripts/lib/review.mjs @@ -8,8 +8,10 @@ import { resolveModel } from './args.mjs'; import { ensurePrivateDirectory, withFileLock } from './fs.mjs'; import { collectGitFacts } from './git.mjs'; import { createJobController, withJobCancellationLock } from './job-control.mjs'; +import { isBoundedPublicIdentifier } from './identifier.mjs'; import { createProgressReporter, waitForCompletionOrAbort } from './progress.mjs'; import { createDeferredConversationProgressObserver } from './conversation-progress.mjs'; +import { createSessionProgressDescriber } from './session-progress.mjs'; import { buildPrompt } from './prompts.mjs'; import { loadReviewOutputSchema, validateJsonSchema } from './review-schema.mjs'; import { resolveWorkspaceStorage } from './workspace.mjs'; @@ -98,6 +100,7 @@ export async function executeJob(input) { deferred: true, ...(input.progressWriter ? { write: input.progressWriter } : {}), persist: (event) => input.store.updateJobProgress(workspace, job.id, event), + persistProbe: (probe) => input.store.updateJobProgressProbe(workspace, job.id, probe), describeNotification: conversationObserver.observe, onDescriptorOverflow: conversationObserver.markGap, ...input.progressDependencies, @@ -106,8 +109,10 @@ export async function executeJob(input) { if (typeof client.subscribeConversation === 'function') { try { const conversationSubscription = await client.subscribeConversation(activeSessionId, { connectionId: `companion-${randomBytes(12).toString('hex')}`, clientMode: 'desktop-continuous' }); + if (!conversationSubscription || !isBoundedPublicIdentifier(conversationSubscription.subscriptionId) || typeof conversationSubscription.unsubscribe !== 'function') throw new Error('invalid conversation subscription'); // Register cleanup before binding can perform any asynchronous work. unsubscribeConversation = conversationSubscription.unsubscribe; + reporter.markConversationSubscribed(); await conversationObserver.bind(conversationSubscription.subscriptionId); } catch { conversationObserver.fail(); reporter.diagnose('conversation-subscribe-failed'); } } else conversationObserver.fail(); @@ -124,10 +129,14 @@ export async function executeJob(input) { }); input.signal?.throwIfAborted(); const beforeMessageIds = [...snapshotMessageIds(snapshot)]; sendAttempted = true; const sent = await boundedStep(() => client.send(activeSessionId, prompt), input.signal); - reporter.activate({ method: 'state.updated', params: { scope: 'session', sessionId: activeSessionId, reason: 'prompt_started' } }); running = await input.store.transitionJob(workspace, job.id, ['running'], 'running', { inputId: sent.inputId, startRevision: sent.stateRevision, beforeMessageIds }); await input.onBoundaryPersisted?.(running); const turnBoundary = { beforeMessageIds: new Set(beforeMessageIds), ...sent }; + try { + const sessionDescriber = await createSessionProgressDescriber({ workspace, turnBoundary }); + reporter.activateAcceptedBoundary({ readSnapshot: () => client.readSession(activeSessionId), describer: sessionDescriber }); + } catch { reporter.activateAcceptedBoundary({}); } + reporter.activate({ method: 'state.updated', params: { scope: 'session', sessionId: activeSessionId, reason: 'prompt_started' } }); await waitForCompletionOrAbort(client.waitForCompletion(activeSessionId), input.signal); await cleanupProgress(); const finalSnapshot = await client.readSession(activeSessionId); diff --git a/scripts/lib/session-progress.mjs b/scripts/lib/session-progress.mjs new file mode 100644 index 00000000..ef8f5f28 --- /dev/null +++ b/scripts/lib/session-progress.mjs @@ -0,0 +1,113 @@ +import { fitProgressMessage, formatToolTerminalMessage } from './conversation-progress.mjs'; +import { isSafeIdentifier } from './identifier.mjs'; + +const START_STATUSES = new Set(['pending', 'running']); +const TERMINAL_STATUSES = new Set(['completed', 'error']); +const SAFE_TOOL_NAMES = new Set(['Bash', 'Read', 'Edit', 'Write', 'Grep', 'Glob', 'WebSearch']); +const MAX_TRACKED_CALLS = 256; +const MAX_SNAPSHOT_MESSAGES = 4_096; + +/** + * Describe only schema-validated session snapshots for the accepted turn. + * @param {{workspace:string,turnBoundary:{inputId:string,stateRevision:number,beforeMessageIds:Set}}} options + */ +export async function createSessionProgressDescriber({ turnBoundary }) { + const beforeMessageIds = turnBoundary.beforeMessageIds; + const inputId = turnBoundary.inputId; + const boundaryRevision = turnBoundary.stateRevision; + const calls = new Map(); + + return { + /** @param {any} snapshot @param {string} observedAt */ + async observe(snapshot, observedAt) { + if (!validTimestamp(observedAt) || !Number.isSafeInteger(snapshot?.runtime?.stateRevision) + || snapshot.runtime.stateRevision < boundaryRevision || !Array.isArray(snapshot?.messages) + || snapshot.messages.length > MAX_SNAPSHOT_MESSAGES || hasUnsafeOrDuplicateMessageRelationships(snapshot.messages)) return []; + const root = selectRoot(snapshot.messages, inputId, beforeMessageIds); + if (!root) return []; + const assistants = snapshot.messages.filter((/** @type {any} */ message) => isLinkedAssistant(message, root.info.messageId, beforeMessageIds)); + const events = []; + for (const assistant of assistants) { + if (!Array.isArray(assistant.parts)) return []; + for (const part of assistant.parts) { + if (part?.type !== 'tool') continue; + const event = describePart(part, observedAt, calls); + if (event) events.push(event); + } + } + return events; + }, + }; +} + +/** @param {any[]} messages */ +function hasUnsafeOrDuplicateMessageRelationships(messages) { + const seen = new Set(); + for (const message of messages) { + const id = message?.info?.messageId; + if (!isSafeIdentifier(id) || message?.info?.role === 'assistant' && !isSafeIdentifier(message.info.parentMessageId) || seen.has(id)) return true; + seen.add(id); + } + return false; +} + +/** @param {any[]} messages @param {string} inputId @param {Set} beforeMessageIds */ +function selectRoot(messages, inputId, beforeMessageIds) { + const direct = messages.filter((message) => message?.info?.messageId === inputId); + if (direct.length === 1 && isVisibleUserRoot(direct[0], beforeMessageIds)) return direct[0]; + if (direct.length !== 0) return null; + const candidates = messages.filter((message) => isVisibleUserRoot(message, beforeMessageIds)); + return candidates.length === 1 ? candidates[0] : null; +} + +/** @param {any} message @param {Set} beforeMessageIds */ +function isVisibleUserRoot(message, beforeMessageIds) { + const info = message?.info; const semantics = info?.semantics; + return info?.role === 'user' && isSafeIdentifier(info.messageId) && !beforeMessageIds.has(info.messageId) + && info.synthetic !== true && info.visibility !== 'model-only' && info.source === undefined + && (semantics === undefined || semantics.origin === 'real_user' && semantics.kind === 'user_prompt' && semantics.uiVisibility === 'visible'); +} + +/** @param {any} message @param {string} rootId @param {Set} beforeMessageIds */ +function isLinkedAssistant(message, rootId, beforeMessageIds) { + const info = message?.info; const semantics = info?.semantics; + return info?.role === 'assistant' && isSafeIdentifier(info.messageId) && !beforeMessageIds.has(info.messageId) + && isSafeIdentifier(info.parentMessageId) && info.parentMessageId === rootId && !['hidden', 'debug'].includes(semantics?.uiVisibility) + && (semantics === undefined || semantics.origin === 'agent_runtime' && semantics.kind === 'assistant_response'); +} + +/** @param {any} part @param {string} observedAt @param {Map} calls */ +function describePart(part, observedAt, calls) { + const callId = part.callId; const state = part.state; + if (!isSafeIdentifier(callId) || !state || typeof state !== 'object') return null; + const toolName = SAFE_TOOL_NAMES.has(part.tool) ? part.tool : ''; + const prior = calls.get(callId); + if (prior?.terminal) return null; + if (START_STATUSES.has(state.status)) { + if (prior?.started || !prior && calls.size >= MAX_TRACKED_CALLS) return null; + const message = fitProgressMessage(snapshotToolStartMessage(toolName)); + calls.set(callId, { started: true, terminal: false, message }); + return { phase: state.status === 'pending' ? 'waiting' : 'running', message, observedAt }; + } + if (!TERMINAL_STATUSES.has(state.status) || !prior && calls.size >= MAX_TRACKED_CALLS) return null; + const startMessage = prior?.message ?? fitProgressMessage(snapshotToolStartMessage(toolName)); + calls.set(callId, { started: prior?.started === true, terminal: true, message: startMessage }); + return { + phase: 'running', + message: fitProgressMessage(formatToolTerminalMessage( + { toolName, startedAt: state.startedAt, endedAt: state.completedAt }, + startMessage, + state.status === 'completed', + )), + observedAt, + }; +} + +/** @param {string} toolName */ +function snapshotToolStartMessage(toolName) { return toolName ? `Running tool: ${toolName}.` : 'Running a tool.'; } + +/** @param {unknown} value */ +function validTimestamp(value) { + if (typeof value !== 'string' || value.length === 0) return false; + try { return new Date(value).toISOString() === value; } catch { return false; } +} diff --git a/scripts/lib/state.mjs b/scripts/lib/state.mjs index a87ac187..43434374 100644 --- a/scripts/lib/state.mjs +++ b/scripts/lib/state.mjs @@ -13,6 +13,7 @@ import { } from './fs.mjs'; import { isSafeIdentifier } from './identifier.mjs'; import { + MAX_PROGRESS_PROBE_COUNT, MAX_PROGRESS_MESSAGE_BYTES, MAX_PROGRESS_PREVIEW_ENTRIES, PROGRESS_PHASES, @@ -193,6 +194,25 @@ export function createStateStore(options) { }); }, + /** @param {string} workspace @param {string} jobId @param {unknown} progressProbe */ + async updateJobProgressProbe(workspace, jobId, progressProbe) { + const boundedProbe = normalizeProgressProbeInput(workspace, jobId, progressProbe); + const storage = await jobStorage(dataRoot, workspace); + return withFileLock(storage.lockPath, async () => { + const path = jobPath(storage.jobsDirectory, jobId); + const job = await readJobRecord(path, jobId, storage.workspacePath); + if (job.status === 'queued' || TERMINAL_STATUSES.has(job.status)) return job; + const updated = { + ...job, + progressProbe: boundedProbe, + updatedAt: new Date(Math.max(Date.now(), Date.parse(job.updatedAt))).toISOString(), + }; + validateJobRecord(updated, jobId, storage.workspacePath); + await atomicWriteJson(path, updated); + return updated; + }); + }, + /** @param {string} workspace @param {string} jobId */ async readJob(workspace, jobId) { const storage = await jobStorage(dataRoot, workspace); @@ -679,6 +699,53 @@ function validateProgressInput(workspace, jobId, event) { if (invalidFields.length > 0) throw invalidProgressInput(invalidFields); } +const PROGRESS_PROBE_STATES = new Set(['probing', 'online', 'snapshot-fallback', 'lifecycle-only']); +const PROGRESS_PROBE_REJECTIONS = ['wire-version', 'envelope-shape', 'sequence', 'topic', 'row-kind', 'row-shape']; + +/** @param {unknown} value */ +export function validProgressProbe(value) { + if (!isPlainJsonObject(value) + || Object.keys(value).sort().join(',') !== 'acceptedInitial,acceptedOnline,acceptedRecovery,framesReceived,rejected,snapshotFallbackActive,snapshotFallbackUnavailable,state,subscriptionAcknowledged' + || !PROGRESS_PROBE_STATES.has(value.state) + || typeof value.subscriptionAcknowledged !== 'boolean' + || typeof value.snapshotFallbackActive !== 'boolean' + || typeof value.snapshotFallbackUnavailable !== 'boolean' + || !isPlainJsonObject(value.rejected) + || Object.keys(value.rejected).sort().join(',') !== [...PROGRESS_PROBE_REJECTIONS].sort().join(',')) return false; + for (const field of ['framesReceived', 'acceptedInitial', 'acceptedOnline', 'acceptedRecovery']) { + if (!boundedProbeCount(value[field])) return false; + } + for (const reason of PROGRESS_PROBE_REJECTIONS) if (!boundedProbeCount(value.rejected[reason])) return false; + if (value.state === 'snapshot-fallback') return value.snapshotFallbackActive && !value.snapshotFallbackUnavailable; + if (value.state === 'lifecycle-only') return !value.snapshotFallbackActive && value.snapshotFallbackUnavailable; + return !value.snapshotFallbackActive && !value.snapshotFallbackUnavailable; +} + +/** @param {unknown} value */ +function boundedProbeCount(value) { return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 && value <= MAX_PROGRESS_PROBE_COUNT; } + +/** @param {unknown} workspace @param {unknown} jobId @param {unknown} progressProbe */ +function normalizeProgressProbeInput(workspace, jobId, progressProbe) { + const invalid = () => new PluginError('JOB_PROGRESS_PROBE_INPUT_INVALID', 'Job progress compatibility probe is invalid.', { + category: 'state', remedy: 'Provide only the fixed bounded progress compatibility schema.', + }); + if (!isNonEmptyString(workspace) || !isDigest(jobId) || !isPlainJsonObject(progressProbe) + || Object.keys(progressProbe).sort().join(',') !== 'acceptedInitial,acceptedOnline,acceptedRecovery,framesReceived,rejected,snapshotFallbackActive,snapshotFallbackUnavailable,state,subscriptionAcknowledged' + || !isPlainJsonObject(progressProbe.rejected) + || Object.keys(progressProbe.rejected).sort().join(',') !== [...PROGRESS_PROBE_REJECTIONS].sort().join(',')) throw invalid(); + const normalized = /** @type {Record} */ ({ ...progressProbe, rejected: { ...progressProbe.rejected } }); + for (const field of ['framesReceived', 'acceptedInitial', 'acceptedOnline', 'acceptedRecovery']) { + if (!Number.isSafeInteger(normalized[field]) || normalized[field] < 0) throw invalid(); + normalized[field] = Math.min(MAX_PROGRESS_PROBE_COUNT, normalized[field]); + } + for (const reason of PROGRESS_PROBE_REJECTIONS) { + if (!Number.isSafeInteger(normalized.rejected[reason]) || normalized.rejected[reason] < 0) throw invalid(); + normalized.rejected[reason] = Math.min(MAX_PROGRESS_PROBE_COUNT, normalized.rejected[reason]); + } + if (!validProgressProbe(normalized)) throw invalid(); + return normalized; +} + /** @param {string[]} invalidFields */ function invalidProgressInput(invalidFields) { return new PluginError('JOB_PROGRESS_INPUT_INVALID', 'Job progress input is invalid.', { @@ -716,7 +783,8 @@ function validateJobRecord(job, expectedJobId, expectedWorkspacePath) { && (!('lastCancelError' in job) || isCancellationError(job.lastCancelError)) && (!('phase' in job) || PROGRESS_PHASES.includes(job.phase)) && (!('lastActivityAt' in job) || isIsoTimestamp(job.lastActivityAt)) - && (!('progressPreview' in job) || validProgressPreview(job.progressPreview)); + && (!('progressPreview' in job) || validProgressPreview(job.progressPreview)) + && (!('progressProbe' in job) || validProgressProbe(job.progressProbe)); const boundaryFields = ['inputId', 'startRevision', 'beforeMessageIds']; const hasBoundary = boundaryFields.some((field) => field in job); const validBoundary = !hasBoundary || boundaryFields.every((field) => field in job) diff --git a/scripts/zcode-companion.mjs b/scripts/zcode-companion.mjs index dded17e4..c3f03643 100644 --- a/scripts/zcode-companion.mjs +++ b/scripts/zcode-companion.mjs @@ -22,7 +22,7 @@ import { executeJob, readResultArtifact } from './lib/review.mjs'; import { reconcileOwnedJobs, scavengeWritableJobs, withWorkerLease } from './lib/recovery.mjs'; import { errorEnvelope, renderOutput } from './lib/render.mjs'; import { createForegroundSignalController } from './lib/signals.mjs'; -import { createStateStore } from './lib/state.mjs'; +import { createStateStore, validProgressProbe } from './lib/state.mjs'; import { resolveWorkspaceStorage } from './lib/workspace.mjs'; import { readWorkspaceModelConfig, summarizeWorkspaceModelConfig } from './lib/workspace-config.mjs'; import { executeTransfer, resolveTransferSource, TRANSFER_WIRE_LIMITS } from './lib/transfer.mjs'; @@ -71,7 +71,7 @@ export async function runCompanion(argv, runtime = {}) { if (parsed.options.all) return { jobs: (await store.listJobs(cwd)).map((job) => publicJob(job, caller.sessionId)), modelPolicy }; let job = await controller.selectOwned(cwd, caller.sessionId, parsed.positionals[0]); if (parsed.options.wait) job = await controller.wait(cwd, job.id, parsed.options.timeoutMs, runtime.signal); - return { job, modelPolicy }; + return { job: publicJob(job, caller.sessionId, true), modelPolicy }; } if (parsed.command === 'result') { const job = await controller.selectOwned(cwd, caller.sessionId, parsed.positionals[0], 'result'); @@ -278,8 +278,8 @@ function requireAuthorization(value, keys) { return value; } function authorizationInputError() { return new PluginError('INTERNAL_AUTHORIZATION_INVALID', 'The internal authorization envelope is invalid.', { category: 'authorization', remedy: 'Invoke this command through its installed skill using the protected internal channel.' }); } -/** @param {any} job @param {string} ownerSessionId */ -function publicJob(job, ownerSessionId) { +/** @param {any} job @param {string} ownerSessionId @param {boolean} [includeProgressProbe] */ +function publicJob(job, ownerSessionId, includeProgressProbe = false) { if (job.ownerSessionId !== ownerSessionId) { return { id: job.id, @@ -289,7 +289,8 @@ function publicJob(job, ownerSessionId) { hasOwner: true, }; } - const visible = { ...job }; delete visible.ownerSessionId; delete visible.ownerTurnId; delete visible.permissionSnapshot; + const visible = { ...job }; delete visible.ownerSessionId; delete visible.ownerTurnId; delete visible.permissionSnapshot; delete visible.progressProbe; + if (includeProgressProbe && validProgressProbe(job.progressProbe)) visible.progressProbe = { ...job.progressProbe, rejected: { ...job.progressProbe.rejected } }; return { ...visible, owned: true, owner: 'same-owner' }; } /** @param {Record} source @param {string[]} fields */ diff --git a/skills/rescue/SKILL.md b/skills/rescue/SKILL.md index 9268d8e6..6508f886 100644 --- a/skills/rescue/SKILL.md +++ b/skills/rescue/SKILL.md @@ -49,7 +49,8 @@ For the generic route, substitute only the preflight-verified absolute canonical ```text Act only as the installed ZCode Rescue forwarder. In the current workspace run exactly: node "/scripts/zcode-companion.mjs" invoke rescue -Preserve stderr and return public stdout verbatim. Do not inspect or modify code independently, interpret results, retry, poll, cancel, choose a pending branch, or request/print/persist authorization material. +Preserve stderr and return public stdout verbatim. Do not inspect or modify code independently, interpret results, retry, cancel, choose a pending branch, or request/print/persist authorization material. +Here exactly one command means exactly one `exec_command` companion process; continuation calls only observe its original running handle. Never start a second `exec_command`. A companion result containing an exit code is terminal. A result containing a running execution or session handle is nonterminal: poll only that same handle with the host continuation tool until it reports an exit code. Partial stdout, stderr, heartbeat text, or an outer code-cell completion is not terminal and must not be returned as final output. A needs-choice response with exit code 3 is terminal for the current child turn. If that command returned a needs-choice response, stop. Only after the parent sends exactly `Continue the pending ZCode Rescue with resume. Run only the installed resume forwarder command and return its public stdout verbatim.` run exactly: node "/scripts/zcode-companion.mjs" invoke-choice rescue resume Only after the parent sends exactly `Continue the pending ZCode Rescue with fresh. Run only the installed fresh forwarder command and return its public stdout verbatim.` run exactly: diff --git a/tests/codex-rescue-qualification.test.mjs b/tests/codex-rescue-qualification.test.mjs index fe0bb5b7..cf56f6f3 100644 --- a/tests/codex-rescue-qualification.test.mjs +++ b/tests/codex-rescue-qualification.test.mjs @@ -26,6 +26,8 @@ const expectedPublicOutput = 'done'; const expectedSemanticProgress = Object.freeze({ start: '[zcode] Running command: npm test.', terminal: '[zcode] Command completed: npm test (25ms).', + snapshotFallback: '[zcode] ZCode conversation frames were unavailable; using bounded session progress.', + lifecycleOnly: '[zcode] ZCode semantic progress is unavailable; lifecycle updates will continue.', }); const backgroundJobId = 'b'.repeat(64); const backgroundPublicOutput = `Reserved background job ${backgroundJobId}.`; @@ -45,6 +47,84 @@ test('qualifies named Rescue from linked parent and child rollout metadata', () }); }); +test('qualifies named and generic Rescue only after the original yielded execution exits', () => { + for (const route of ['named', 'generic']) { + const input = yieldedFixture(); + if (route === 'generic') { + const args = JSON.parse(spawnEvent(input).payload.arguments); delete args.agent_type; args.message = 'fixed generic forwarder'; + spawnEvent(input).payload.arguments = JSON.stringify(args); + childMeta(input).payload.source.subagent.thread_spawn.agent_role = null; + } + assert.equal(qualifyCodexRescueEvidence(input, options()).route, route === 'named' ? 'named' : 'generic-schema-hidden'); + } +}); + +test('required yielded qualification exposes only non-sensitive execution facts', () => { + const input = yieldedFixture(); setYieldedHandle(input, 987654321); + const evidence = qualifyCodexRescueEvidence(input, options({ requireYieldedExecution: true })); + assert.deepEqual(evidence.yieldedExecution, { + execCommandCount: 1, + pollCount: 2, + sameHandleChecked: true, + terminalExitCode: 0, + }); + const serialized = JSON.stringify(evidence); + assert.equal(serialized.includes('987654321'), false); + assert.equal(serialized.includes('qualification-capability-sentinel-private'), false); + + const encrypted = yieldedFixture(); + setYieldedHandle(encrypted, 987654321); + const args = JSON.parse(spawnEvent(encrypted).payload.arguments); args.message = `gAAAA${'A'.repeat(64)}`; spawnEvent(encrypted).payload.arguments = JSON.stringify(args); + assert.throws( + () => qualifyCodexRescueEvidence(encrypted, options({ requireYieldedExecution: true })), + (error) => error instanceof CodexRescueUnqualifiedError + && !JSON.stringify(error.evidence).includes('987654321') + && !JSON.stringify(error.evidence).includes('qualification-capability-sentinel-private'), + ); +}); + +test('yielded Rescue qualification rejects process replacement, handle drift, input, missing exit, and terminal-order violations', () => { + const cases = [ + { code: 'child-command-count', mutate: (input) => input.rollouts[1].splice(3, 0, structuredExecResult(expectedCommand, 'exec-2'), capturedResultEvent('exec-2', { output: '', session_id: 42 })) }, + { code: 'child-handle-mismatch', mutate: (input) => { childPolls(input)[0].payload.input = structuredPoll(42, 'poll-1', '').payload.input; } }, + { code: 'child-poll-input', mutate: (input) => { childPolls(input)[0].payload.input = structuredPoll(41, 'poll-1', 'x').payload.input; } }, + { code: 'child-terminal-exit-missing', mutate: (input) => { childPollOutputs(input).at(-1).payload.output = capturedResult({ output: `${expectedPublicOutput}\n`, session_id: 41 }); } }, + { code: 'child-terminal-order', mutate: (input) => { const final = input.rollouts[1].pop(); input.rollouts[1].splice(4, 0, final); } }, + { code: 'parent-terminal-order', mutate: (input) => { const final = input.rollouts[0].pop(); input.rollouts[0].splice(5, 0, final); } }, + { code: 'child-poll-after-terminal', mutate: (input) => input.rollouts[1].splice(-1, 0, structuredPoll(41, 'poll-3'), capturedResultEvent('poll-3', { output: '', exit_code: 0 })) }, + ]; + for (const { code, mutate } of cases) { + const input = yieldedFixture(); mutate(input); + assert.throws( + () => qualifyCodexRescueEvidence(input, options()), + (error) => error instanceof CodexRescueEvidenceMismatchError && error.code === code, + code, + ); + } +}); + +test('foreground structured execution requires an exact zero exit code', () => { + for (const exitCode of [1, 130]) { + const input = yieldedFixture(); childPollOutputs(input).at(-1).payload.output = capturedResult({ output: `${expectedSemanticProgress.terminal}\n${expectedPublicOutput}\n`, exit_code: exitCode }); + assert.throws(() => qualifyCodexRescueEvidence(input, options()), (error) => error instanceof CodexRescueEvidenceMismatchError && error.code === 'child-terminal-exit-invalid', String(exitCode)); + } +}); + +test('child host evidence requires exact exec tool names and one-to-one unique call IDs', () => { + const cases = [ + { code: 'child-tool-name', mutate: (input) => { childExec(input).payload.name = 'other'; } }, + { code: 'child-call-id', mutate: (input) => { delete childExec(input).payload.call_id; } }, + { code: 'child-call-id', mutate: (input) => { childPolls(input)[1].payload.call_id = 'poll-1'; } }, + { code: 'child-call-id', mutate: (input) => { delete childPollOutputs(input)[0].payload.call_id; } }, + { code: 'child-call-id', mutate: (input) => { childPollOutputs(input)[1].payload.call_id = 'poll-1'; } }, + { code: 'child-call-id', mutate: (input) => { childPollOutputs(input)[1].payload.call_id = 'orphan-output'; } }, + ]; + for (const { code, mutate } of cases) { + const input = yieldedFixture(); mutate(input); + assert.throws(() => qualifyCodexRescueEvidence(input, options()), (error) => error instanceof CodexRescueEvidenceMismatchError && error.code === code, code); + } +}); + test('foreground qualification fails closed unless child transcript contains exact semantic start and terminal progress', () => { for (const missing of ['start', 'terminal']) { const input = fixture(); @@ -58,6 +138,25 @@ test('foreground qualification fails closed unless child transcript contains exa } }); +test('foreground qualification accepts either exact compatibility diagnostic while retaining yielded child exit proof', () => { + for (const diagnostic of ['snapshotFallback', 'lifecycleOnly']) { + const input = yieldedFixture(); + childPollOutputs(input).at(-1).payload.output = capturedResult({ + output: `${expectedSemanticProgress[diagnostic]}\n${expectedPublicOutput}\n`, exit_code: 0, + }); + const evidence = qualifyCodexRescueEvidence(input, options({ requireYieldedExecution: true })); + assert.equal(evidence.semanticProgressChecked, true, diagnostic); + assert.equal(evidence.yieldedExecution.terminalExitCode, 0, diagnostic); + } + + const startupOnly = yieldedFixture(); + childPollOutputs(startupOnly).at(-1).payload.output = capturedResult({ output: `[zcode] ZCode started the delegated turn.\n${expectedPublicOutput}\n`, exit_code: 0 }); + assert.throws( + () => qualifyCodexRescueEvidence(startupOnly, options({ requireYieldedExecution: true })), + (error) => error instanceof CodexRescueEvidenceMismatchError && error.code === 'semantic-progress-missing', + ); +}); + test('qualifies named and generic background Rescue with one linked queued output and no capability leak', () => { const named = backgroundFixture(); assert.deepEqual(qualifyCodexRescueBackgroundEvidence(named, backgroundOptions()), { @@ -84,7 +183,7 @@ test('background qualification fails closed on inline, self-printed, wrong-child { code: 'background-child-stdout', mutate: (input) => { childOutput(input).payload.output.unshift({ type: 'input_text', text: 'noisy progress\n' }); } }, { code: 'child-rollout-id-mismatch', mutate: (input) => { startEvent(input).payload.agent_thread_id = 'wrong-child'; } }, { code: 'spawn-count', mutate: (input) => input.rollouts[0].splice(4, 0, structuredSpawn('spawn-2')) }, - { code: 'child-output-link', mutate: (input) => { childOutput(input).payload.call_id = 'unlinked-output'; } }, + { code: 'child-call-id', mutate: (input) => { childOutput(input).payload.call_id = 'unlinked-output'; } }, { code: 'background-capability-leak', mutate: (input) => { const args = JSON.parse(spawnEvent(input).payload.arguments); args.message = `${args.message} ${executionCapability}`; spawnEvent(input).payload.arguments = JSON.stringify(args); } }, { code: 'background-capability-leak', mutate: (input) => { input.rollouts[0].splice(-1, 0, { type: 'event_msg', payload: { type: 'agent_message', message: executionCapability, phase: 'commentary' } }); } }, { code: 'background-capability-leak', mutate: (input) => { childOutput(input).payload.output[0].text += executionCapability; } }, @@ -135,6 +234,99 @@ test('qualifies exact resume and fresh follow-ups against one existing child ID' } }); +test('choice qualification permits yielded polling in the initial turn, continuation turn, or both', () => { + for (const turns of [['initial'], ['continuation'], ['initial', 'continuation']]) { + const input = choiceFixture('resume'); + for (const turn of turns) yieldChoiceTurn(input, turn); + retimestampChoice(input); + assert.equal(qualifyCodexRescueChoiceEvidence(input, choiceOptions('resume')).choice, 'resume', turns.join('+')); + } +}); + +test('choice child rollout accounts for every host event and exact exec tool name', () => { + const extraCall = () => structuredPoll(77, 'outside'); const extraOutput = () => capturedResultEvent('outside', { output: '', exit_code: 0 }); + const cases = [ + { mutate: (input) => input.rollouts[1].unshift(extraCall(), extraOutput()) }, + { mutate: (input) => input.rollouts[1].push(extraCall(), extraOutput()) }, + { mutate: (input) => input.rollouts[1].push(extraOutput()) }, + { code: 'choice-initial-tool-name', mutate: (input) => { input.rollouts[1][1].payload.name = 'other'; } }, + { code: 'choice-continuation-tool-name', mutate: (input) => { input.rollouts[1][4].payload.name = 'other'; } }, + ]; + for (const { code = 'choice-child-execution-boundary', mutate } of cases) { + const input = choiceFixture('resume'); mutate(input); retimestampChoice(input); + assert.throws(() => qualifyCodexRescueChoiceEvidence(input, choiceOptions('resume')), (error) => error instanceof CodexRescueEvidenceMismatchError && error.code === code, code); + } +}); + +test('choice host and parent evidence has globally unique one-to-one call IDs', () => { + const cases = [ + { code: 'choice-initial-call-id', mutate: (input) => { delete input.rollouts[1][1].payload.call_id; } }, + { code: 'choice-continuation-call-id', mutate: (input) => { delete input.rollouts[1][5].payload.call_id; } }, + { code: 'choice-child-call-id-reused', mutate: (input) => { input.rollouts[1][4].payload.call_id = 'exec-1'; input.rollouts[1][5].payload.call_id = 'exec-1'; } }, + { code: 'choice-parent-call-id', mutate: (input) => { delete choiceFollowup(input).payload.call_id; } }, + { code: 'choice-parent-call-id', mutate: (input) => { const waits = input.rollouts[0].filter((event) => event.payload?.name === 'wait_agent'); waits[1].payload.call_id = 'wait-1'; input.rollouts[0].findLast((event) => event.payload?.type === 'function_call_output').payload.call_id = 'wait-1'; } }, + { code: 'choice-parent-call-id', mutate: (input) => input.rollouts[0].push(followupOutput('orphan-parent')) }, + ]; + for (const { code, mutate } of cases) { + const input = choiceFixture('resume'); mutate(input); + assert.throws(() => qualifyCodexRescueChoiceEvidence(input, choiceOptions('resume')), (error) => error instanceof CodexRescueEvidenceMismatchError && error.code === code, code); + } +}); + +test('choice parent call IDs are globally owned across custom preflight and function tools', () => { + const cases = [ + { mutate: (input) => { delete preflightEvent(input).payload.call_id; } }, + { mutate: (input) => { delete preflightOutput(input).payload.call_id; } }, + { mutate: (input) => { preflightEvent(input).payload.call_id = 'spawn-1'; preflightOutput(input).payload.call_id = 'spawn-1'; } }, + { mutate: (input) => { preflightEvent(input).payload.call_id = 'wait-1'; preflightOutput(input).payload.call_id = 'wait-1'; } }, + { mutate: (input) => { preflightEvent(input).payload.call_id = 'followup-1'; preflightOutput(input).payload.call_id = 'followup-1'; } }, + { mutate: (input) => { const wait = waitResult(input, 'wait-1'); [preflightOutput(input).payload.call_id, wait.payload.call_id] = [wait.payload.call_id, preflightOutput(input).payload.call_id]; } }, + { mutate: (input) => { input.rollouts[0].push(toolOutput('orphan-preflight', 'orphan')); } }, + ]; + for (const { mutate } of cases) { + const input = choiceFixture('resume'); mutate(input); + assert.throws( + () => qualifyCodexRescueChoiceEvidence(input, choiceOptions('resume')), + (error) => error instanceof CodexRescueEvidenceMismatchError && error.code === 'choice-parent-call-id', + ); + } +}); + +test('choice yielded executions reject changed handles, nonempty input, missing exit, and polling after terminal', () => { + const cases = [ + { code: 'choice-initial-handle-mismatch', turn: 'initial', mutate: (events) => { events.poll.payload.input = structuredPoll(52, events.poll.payload.call_id).payload.input; } }, + { code: 'choice-initial-poll-input', turn: 'initial', mutate: (events) => { events.poll.payload.input = structuredPoll(51, events.poll.payload.call_id, 'x').payload.input; } }, + { code: 'choice-initial-terminal-exit-missing', turn: 'initial', mutate: (events) => { events.terminal.payload.output = capturedResult({ output: events.terminalText, session_id: 51 }); } }, + { code: 'choice-initial-poll-after-terminal', turn: 'initial', mutate: (events, input) => { input.rollouts[1].splice(input.rollouts[1].indexOf(events.final), 0, structuredPoll(51, 'choice-initial-late-poll'), capturedResultEvent('choice-initial-late-poll', { output: '', exit_code: 0 })); } }, + { code: 'choice-continuation-handle-mismatch', turn: 'continuation', mutate: (events) => { events.poll.payload.input = structuredPoll(62, events.poll.payload.call_id).payload.input; } }, + { code: 'choice-continuation-poll-input', turn: 'continuation', mutate: (events) => { events.poll.payload.input = structuredPoll(61, events.poll.payload.call_id, 'x').payload.input; } }, + { code: 'choice-continuation-terminal-exit-missing', turn: 'continuation', mutate: (events) => { events.terminal.payload.output = capturedResult({ output: events.terminalText, session_id: 61 }); } }, + { code: 'choice-continuation-poll-after-terminal', turn: 'continuation', mutate: (events, input) => { input.rollouts[1].splice(input.rollouts[1].indexOf(events.final), 0, structuredPoll(61, 'choice-late-poll'), capturedResultEvent('choice-late-poll', { output: '', exit_code: 0 })); } }, + ]; + for (const { code, turn, mutate } of cases) { + const input = choiceFixture('resume'); const events = yieldChoiceTurn(input, turn); mutate(events, input); retimestampChoice(input); + assert.throws(() => qualifyCodexRescueChoiceEvidence(input, choiceOptions('resume')), (error) => error instanceof CodexRescueEvidenceMismatchError && error.code === code, code); + } +}); + +test('needs-choice is terminal only with exit code 3 before same-child continuation', () => { + const valid = choiceFixture('resume'); + assert.equal(qualifyCodexRescueChoiceEvidence(valid, choiceOptions('resume')).choice, 'resume'); + const wrong = choiceFixture('resume'); + wrong.rollouts[1][2].payload.output = capturedResult({ output: `${JSON.stringify({ type: 'needs-choice', candidate: { sessionId: 'resumable-session' }, choices: ['--resume', '--fresh'] })}\n`, exit_code: 0 }); + assert.throws( + () => qualifyCodexRescueChoiceEvidence(wrong, choiceOptions('resume')), + (error) => error instanceof CodexRescueEvidenceMismatchError && error.code === 'choice-needs-choice-exit', + ); +}); + +test('selected choice continuation requires an exact zero exit code', () => { + for (const exitCode of [1, 130]) { + const input = choiceFixture('resume'); input.rollouts[1][5].payload.output = capturedResult({ output: `${expectedPublicOutput}\n`, exit_code: exitCode }); + assert.throws(() => qualifyCodexRescueChoiceEvidence(input, choiceOptions('resume')), (error) => error instanceof CodexRescueEvidenceMismatchError && error.code === 'choice-continuation-terminal-exit-invalid', String(exitCode)); + } +}); + test('shared parent-child route validation fails every trusted metadata field closed', () => { const cases = [ (input) => { parentMeta(input).payload.session_id = 'wrong'; }, @@ -175,13 +367,13 @@ test('choice qualification fails closed on duplicate execution, identity drift, { code: 'choice-spawn-count', mutate: (input) => input.rollouts[0].splice(4, 0, structuredSpawn('spawn-2')) }, { code: 'choice-spawn-keys', mutate: (input) => { const args = JSON.parse(spawnEvent(input).payload.arguments); args.task = 'leak'; spawnEvent(input).payload.arguments = JSON.stringify(args); } }, { code: 'choice-agent-role', mutate: (input) => { childMeta(input).payload.source.subagent.thread_spawn.agent_role = null; } }, - { code: 'choice-followup-count', mutate: (input) => input.rollouts[0].splice(-1, 0, structuredFollowup('followup-2', 'resume')) }, + { code: 'choice-followup-count', mutate: (input) => input.rollouts[0].splice(-1, 0, structuredFollowup('followup-2', 'resume'), followupOutput('followup-2')) }, { code: 'choice-followup-target', mutate: (input) => { choiceFollowup(input).payload.arguments = JSON.stringify({ target: 'sibling-child', message: choiceOptions('resume').expectedFollowupMessage }); } }, { code: 'choice-followup-message', mutate: (input) => { choiceFollowup(input).payload.arguments = JSON.stringify({ target: childId, message: `${choiceOptions('resume').expectedFollowupMessage} task text` }); } }, - { code: 'choice-followup-output-link', mutate: (input) => { followupResult(input).payload.call_id = 'foreign'; } }, + { code: 'choice-parent-call-id', mutate: (input) => { followupResult(input).payload.call_id = 'foreign'; } }, { code: 'choice-followup-output-order', mutate: (input) => { const output = input.rollouts[0].splice(input.rollouts[0].indexOf(followupResult(input)), 1)[0]; input.rollouts[0].splice(input.rollouts[0].indexOf(choiceFollowup(input)), 0, output); } }, - { code: 'choice-wait-count', mutate: (input) => { input.rollouts[0] = input.rollouts[0].filter((event) => event?.payload?.name !== 'wait_agent'); } }, - { code: 'choice-wait-output-link', mutate: (input) => { input.rollouts[0] = input.rollouts[0].filter((event) => event?.payload?.call_id !== 'wait-1' || event?.payload?.type !== 'function_call_output'); } }, + { code: 'choice-wait-count', mutate: (input) => { const waitIds = new Set(input.rollouts[0].filter((event) => event?.payload?.name === 'wait_agent').map((event) => event.payload.call_id)); input.rollouts[0] = input.rollouts[0].filter((event) => !waitIds.has(event?.payload?.call_id)); } }, + { code: 'choice-parent-call-id', mutate: (input) => { input.rollouts[0] = input.rollouts[0].filter((event) => event?.payload?.call_id !== 'wait-1' || event?.payload?.type !== 'function_call_output'); } }, { code: 'choice-wait-output-shape', mutate: (input) => { waitResult(input, 'wait-1').payload.output = JSON.stringify({ message: 'Wait completed.', timed_out: true }); } }, { code: 'choice-wait-return-order', mutate: (input) => { const output = waitResult(input, 'wait-2'); input.rollouts[0].splice(input.rollouts[0].indexOf(output), 1); input.rollouts[0].push(output); } }, { code: 'choice-command-count', mutate: (input) => input.rollouts[1].splice(-1, 0, structuredExec(expectedCommand, 'exec-3')) }, @@ -195,6 +387,7 @@ test('choice qualification fails closed on duplicate execution, identity drift, assert.throws( () => qualifyCodexRescueChoiceEvidence(input, choiceOptions('resume')), (error) => error instanceof CodexRescueEvidenceMismatchError && error.code === code, + code, ); } }); @@ -228,8 +421,8 @@ test('choice qualification marks only explicitly encrypted continuation argument const observableMismatch = choiceFixture('resume'); choiceFollowup(observableMismatch).payload.arguments = JSON.stringify({ target: childId, message: `gAAAA${'A'.repeat(80)}=` }); waitResult(observableMismatch, 'wait-1').payload.call_id = 'wrong'; - assert.throws(() => qualifyCodexRescueChoiceEvidence(observableMismatch, choiceOptions('resume')), (error) => error instanceof CodexRescueEvidenceMismatchError && error.code === 'choice-wait-output-link'); - const missing = choiceFixture('resume'); missing.rollouts[0] = missing.rollouts[0].filter((event) => event !== choiceFollowup(missing)); + assert.throws(() => qualifyCodexRescueChoiceEvidence(observableMismatch, choiceOptions('resume')), (error) => error instanceof CodexRescueEvidenceMismatchError && error.code === 'choice-parent-call-id'); + const missing = choiceFixture('resume'); const missingCall = choiceFollowup(missing); const missingOutput = followupResult(missing); missing.rollouts[0] = missing.rollouts[0].filter((event) => event !== missingCall && event !== missingOutput); assert.throws( () => qualifyCodexRescueChoiceEvidence(missing, choiceOptions('resume')), (error) => error instanceof CodexRescueEvidenceMismatchError && error.code === 'choice-followup-count', @@ -281,7 +474,7 @@ test('binds child stdout to the unique exec call and terminal sentinel', () => { const cases = [ { code: 'child-output-count', mutate: (input) => input.rollouts[1].splice(2, 1) }, { code: 'child-output-count', mutate: (input) => input.rollouts[1].splice(3, 0, toolOutput('exec-1', `${expectedPublicOutput}\n`)) }, - { code: 'child-output-link', mutate: (input) => { childOutput(input).payload.call_id = 'wrong-call'; } }, + { code: 'child-call-id', mutate: (input) => { childOutput(input).payload.call_id = 'wrong-call'; } }, { code: 'child-output-mismatch', mutate: (input) => { childOutput(input).payload.output = toolOutput('exec-1', semanticText('not-done\n')).payload.output; } }, { code: 'child-output-mismatch', mutate: (input) => { childOutput(input).payload.output = toolOutput('exec-1', semanticText(`${expectedPublicOutput}\nprogress-after\n`)).payload.output; } }, { code: 'child-output-mismatch', mutate: (input) => { childOutput(input).payload.output = toolOutput('exec-1', semanticText(`${expectedPublicOutput}\n${expectedPublicOutput}\n`)).payload.output; } }, @@ -467,13 +660,16 @@ test('fails an observed but unsupported function_call exec_command shape', () => ); }); -test('fails when child-only stderr or raw progress enters a parent public event', () => { - const input = fixture(); - input.rollouts[0].splice(-2, 0, { type: 'event_msg', payload: { type: 'agent_message', message: 'raw output must stay private', phase: 'commentary' } }); - assert.throws( - () => qualifyCodexRescueEvidence(input, options()), - (error) => error instanceof CodexRescueEvidenceMismatchError && error.code === 'parent-isolation-breach', - ); +test('fails when child-only stderr or either compatibility diagnostic enters a parent public event', () => { + for (const forbidden of ['raw output must stay private', expectedSemanticProgress.snapshotFallback, expectedSemanticProgress.lifecycleOnly]) { + const input = fixture(); + input.rollouts[0].splice(-2, 0, { type: 'event_msg', payload: { type: 'agent_message', message: forbidden, phase: 'commentary' } }); + assert.throws( + () => qualifyCodexRescueEvidence(input, options()), + (error) => error instanceof CodexRescueEvidenceMismatchError && error.code === 'parent-isolation-breach', + forbidden, + ); + } }); test('fails when the parent executes the constant Rescue command inline', () => { @@ -494,6 +690,44 @@ test('requires exact child, parent rollout, and exec terminal public output', () ); }); +test('rejects parent child-return and final timestamps that precede the child terminal exit', () => { + const input = fixture(); + childReturnEvent(input).timestamp = '2026-08-10T00:00:00.000004Z'; + input.rollouts[0].find((event) => event?.payload?.phase === 'final_answer').timestamp = '2026-08-10T00:00:00.000005Z'; + assert.throws( + () => qualifyCodexRescueEvidence(input, options()), + (error) => error instanceof CodexRescueEvidenceMismatchError && error.code === 'parent-terminal-timeline', + ); +}); + +test('fails closed when cross-rollout terminal causality lacks a trusted timestamp', () => { + const input = fixture(); delete childReturnEvent(input).timestamp; + assert.throws( + () => qualifyCodexRescueEvidence(input, options()), + (error) => error instanceof CodexRescueEvidenceMismatchError && error.code === 'parent-terminal-timeline', + ); +}); + +test('terminal causality rejects impossible RFC3339 calendar, clock, and offset fields', () => { + for (const timestamp of [ + '2025-02-29T00:00:00Z', '2024-02-30T00:00:00Z', '2024-04-31T00:00:00Z', '2024-13-01T00:00:00Z', + '2024-01-01T24:00:00Z', '2024-01-01T00:60:00Z', '2024-01-01T00:00:60Z', + '2024-01-01T00:00:00+24:00', '2024-01-01T00:00:00+00:60', + ]) { + const input = fixture(); childOutput(input).timestamp = timestamp; + assert.throws(() => qualifyCodexRescueEvidence(input, options()), (error) => error instanceof CodexRescueEvidenceMismatchError && error.code === 'parent-terminal-timeline', timestamp); + } +}); + +test('terminal causality accepts valid leap day, timezone offsets, and exact nanoseconds', () => { + const input = fixture(); + childOutput(input).timestamp = '2024-02-29T23:59:59.123456786+00:00'; + input.rollouts[1].find((event) => event?.payload?.phase === 'final_answer').timestamp = '2024-02-29T23:59:59.123456787Z'; + childReturnEvent(input).timestamp = '2024-03-01T07:59:59.123456788+08:00'; + input.rollouts[0].find((event) => event?.payload?.phase === 'final_answer').timestamp = '2024-03-01T00:00:00.000000000Z'; + assert.equal(qualifyCodexRescueEvidence(input, options()).publicOutput, expectedPublicOutput); +}); + test('accepts bounded commentary agent messages before one exact final sentinel and terminal turn', () => { const input = fixture(); input.execFrames.splice(-2, 0, @@ -554,7 +788,10 @@ function options(overrides = {}) { expectedSemanticProgress, expectedNamedSpawnMessage: 'fixed named forwarder', expectedGenericSpawnMessage: 'fixed generic forwarder', - forbiddenParentText: ['Running command: npm test', 'raw output must stay private', 'reasoning must stay private'], + forbiddenParentText: [ + 'Running command: npm test', expectedSemanticProgress.snapshotFallback, expectedSemanticProgress.lifecycleOnly, + 'raw output must stay private', 'reasoning must stay private', + ], ...overrides, }; return value; @@ -586,10 +823,37 @@ function fixture(publicOutput = expectedPublicOutput) { structuredExec(expectedCommand), toolOutput('exec-1', `${expectedSemanticProgress.start}\n${expectedSemanticProgress.terminal}\n${publicOutput}\n`), { type: 'event_msg', payload: { type: 'agent_message', message: publicOutput, phase: 'final_answer' } }, - ]; + ]; + child[2].timestamp = '2026-08-10T00:00:00.000006Z'; child[3].timestamp = '2026-08-10T00:00:00.000007Z'; + parent[5].timestamp = '2026-08-10T00:00:00.000008Z'; parent[6].timestamp = '2026-08-10T00:00:00.000009Z'; return { execFrames, rollouts: [parent, child] }; } +function yieldedFixture() { + const input = fixture(); + input.rollouts[1].splice(1, 2, + structuredExecResult(expectedCommand, 'exec-1'), + capturedResultEvent('exec-1', { output: `${expectedSemanticProgress.start}\n`, session_id: 41 }), + structuredPoll(41, 'poll-1'), + capturedResultEvent('poll-1', { output: 'still running\n', session_id: 41 }), + structuredPoll(41, 'poll-2'), + capturedResultEvent('poll-2', { output: `${expectedSemanticProgress.terminal}\n${expectedPublicOutput}\n`, exit_code: 0 })); + const child = input.rollouts[1]; const parent = input.rollouts[0]; + child.find((event) => event?.payload?.call_id === 'poll-2' && event.payload.type === 'custom_tool_call_output').timestamp = '2026-08-10T00:00:00.000006Z'; + child.find((event) => event?.payload?.phase === 'final_answer').timestamp = '2026-08-10T00:00:00.000007Z'; + parent.find((event) => event?.payload?.author === agentPath).timestamp = '2026-08-10T00:00:00.000008Z'; + parent.find((event) => event?.payload?.phase === 'final_answer').timestamp = '2026-08-10T00:00:00.000009Z'; + return input; +} + +function setYieldedHandle(input, handle) { + const calls = childPolls(input); const outputs = input.rollouts[1].filter((event) => event.payload?.type === 'custom_tool_call_output'); + outputs[0].payload.output = capturedResult({ output: `${expectedSemanticProgress.start}\n`, session_id: handle }); + outputs[1].payload.output = capturedResult({ output: 'still running\n', session_id: handle }); + calls[0].payload.input = structuredPoll(handle, calls[0].payload.call_id).payload.input; + calls[1].payload.input = structuredPoll(handle, calls[1].payload.call_id).payload.input; +} + function backgroundFixture() { const input = fixture(backgroundPublicOutput); childOutput(input).payload.output = [{ type: 'input_text', text: `${backgroundPublicOutput}\n` }]; return input; } function choiceOptions(choice) { @@ -633,11 +897,11 @@ function choiceFixture(choice) { ]; const child = [ { type: 'session_meta', payload: { session_id: parentId, id: childId, parent_thread_id: parentId, thread_source: 'subagent', source: { subagent: { thread_spawn: { parent_thread_id: parentId, depth: 1, agent_path: agentPath, agent_nickname: 'Ada', agent_role: 'zcode-rescue' } } } } }, - structuredExec(expectedCommand, 'exec-1'), - toolOutput('exec-1', needsChoice), + structuredExecResult(expectedCommand, 'exec-1'), + capturedResultEvent('exec-1', { output: needsChoice, exit_code: 3 }), { type: 'event_msg', payload: { type: 'agent_message', message: needsChoice, phase: 'final_answer' } }, - structuredExec(choiceOptions(choice).expectedChoiceCommand, 'exec-2'), - toolOutput('exec-2', `${expectedPublicOutput}\n`), + structuredExecResult(choiceOptions(choice).expectedChoiceCommand, 'exec-2'), + capturedResultEvent('exec-2', { output: `${expectedPublicOutput}\n`, exit_code: 0 }), { type: 'event_msg', payload: { type: 'agent_message', message: expectedPublicOutput, phase: 'final_answer' } }, ]; const at = (event, offset) => { event.timestamp = new Date(Date.parse('2026-08-10T00:00:00.000Z') + offset).toISOString(); }; @@ -646,6 +910,31 @@ function choiceFixture(choice) { return { rollouts: [parent, child] }; } +function yieldChoiceTurn(input, turn) { + const child = input.rollouts[1]; const finals = child.filter((event) => event?.payload?.phase === 'final_answer'); + const final = finals[turn === 'initial' ? 0 : 1]; const start = turn === 'initial' ? 1 : child.indexOf(finals[0]) + 1; + const call = child[start]; const output = child[start + 1]; const handle = turn === 'initial' ? 51 : 61; + const terminalText = turn === 'initial' ? JSON.parse(output.payload.output[1].text).output : expectedPublicOutput + '\n'; + const terminalExit = turn === 'initial' ? 3 : 0; const prefix = turn === 'initial' ? 'choice-initial' : 'choice-continuation'; + call.payload.input = structuredExecResult(turn === 'initial' ? expectedCommand : choiceOptions('resume').expectedChoiceCommand, call.payload.call_id).payload.input; + output.payload.output = capturedResult({ output: 'partial\n', session_id: handle }); + const poll = structuredPoll(handle, `${prefix}-poll`); const pollOutput = capturedResultEvent(`${prefix}-poll`, { output: 'heartbeat\n', session_id: handle }); + const terminalPoll = structuredPoll(handle, `${prefix}-terminal`); const terminal = capturedResultEvent(`${prefix}-terminal`, { output: terminalText, exit_code: terminalExit }); + child.splice(start + 2, 0, poll, pollOutput, terminalPoll, terminal); + return { call, output, poll, pollOutput, terminalPoll, terminal, final, terminalText }; +} + +function retimestampChoice(input) { + const parent = input.rollouts[0]; const child = input.rollouts[1]; const childFinals = child.filter((event) => event?.payload?.phase === 'final_answer'); + const returns = parent.filter((event) => event?.payload?.author === agentPath); const parentFinals = parent.filter((event) => event?.payload?.phase === 'final_answer'); + const followup = choiceFollowup(input); const followupResultEvent = followupResult(input); let offset = 4; + const stamp = (event) => { event.timestamp = new Date(Date.parse('2026-08-10T00:00:00.000Z') + offset++).toISOString(); }; + for (const event of child.slice(1, child.indexOf(childFinals[0]) + 1)) stamp(event); + stamp(returns[0]); stamp(parentFinals[0]); stamp(followup); stamp(followupResultEvent); + for (const event of child.slice(child.indexOf(childFinals[0]) + 1, child.indexOf(childFinals[1]) + 1)) stamp(event); + stamp(returns[1]); stamp(parentFinals[1]); +} + function timeoutFixture() { const input = choiceFixture('resume'); const firstReturn = input.rollouts[0].findIndex((event) => event?.payload?.author === agentPath); input.rollouts[0].splice(firstReturn, 0, structuredWait('wait-timeout'), waitOutput('wait-timeout', true), structuredList('list-after-timeout'), listOutput('list-after-timeout'), { type: 'event_msg', payload: { type: 'user_message', message: 'status?' } }, structuredWait('wait-after-steering'), waitOutput('wait-after-steering', false)); return input; @@ -675,6 +964,25 @@ function structuredExec(command, callId = 'exec-1', fields = {}) { return { type: 'response_item', payload: { type: 'custom_tool_call', name: 'exec', call_id: callId, input: `const r = await tools.exec_command(${JSON.stringify({ cmd: command, workdir: expectedWorkspace, ...fields })});\ntext(r.output);\n` } }; } +function structuredExecResult(command, callId) { + return { type: 'response_item', payload: { type: 'custom_tool_call', name: 'exec', call_id: callId, input: `const r = await tools.exec_command(${JSON.stringify({ cmd: command, workdir: expectedWorkspace })}); text(JSON.stringify(r))\n` } }; +} + +function structuredPoll(sessionId, callId, chars = '') { + return { type: 'response_item', payload: { type: 'custom_tool_call', name: 'exec', call_id: callId, input: `const r = await tools.write_stdin(${JSON.stringify({ session_id: sessionId, chars })}); text(JSON.stringify(r))\n` } }; +} + +function capturedResult(result) { + return [{ type: 'input_text', text: 'Script completed\nWall time 0.1 seconds\nOutput:\n' }, { type: 'input_text', text: JSON.stringify(result) }]; +} + +function capturedResultEvent(callId, result) { + return { type: 'response_item', payload: { type: 'custom_tool_call_output', call_id: callId, output: capturedResult(result) } }; +} + +function childPolls(input) { return input.rollouts[1].filter((event) => event.payload?.type === 'custom_tool_call').slice(1); } +function childPollOutputs(input) { return input.rollouts[1].filter((event) => event.payload?.type === 'custom_tool_call_output').slice(1); } + function structuredExecUnquoted(command) { return { type: 'response_item', payload: { type: 'custom_tool_call', name: 'exec', call_id: 'exec-1', input: `const r = await tools.exec_command({cmd:${JSON.stringify(command)},workdir:"/repo"});\ntext(r.output);\n` } }; } diff --git a/tests/conversation-progress.test.mjs b/tests/conversation-progress.test.mjs index c59b6d7a..8113fd83 100644 --- a/tests/conversation-progress.test.mjs +++ b/tests/conversation-progress.test.mjs @@ -5,11 +5,48 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import test from 'node:test'; -import { createConversationProgressDescriber, createDeferredConversationProgressObserver, normalizePreview } from '../scripts/lib/conversation-progress.mjs'; +import { createConversationProgressDescriber as createStructuralDescriber, createDeferredConversationProgressObserver as createStructuralDeferredObserver, normalizePreview } from '../scripts/lib/conversation-progress.mjs'; import { conversationFrame, toolRow, turnRow } from './fixtures/conversation-progress-frames.mjs'; const observedAt = '2026-08-09T00:00:01.000Z'; +async function createConversationProgressDescriber(...args) { + const describer = await createStructuralDescriber(...args); + return { ...describer, observe: async (...observeArgs) => (await describer.observe(...observeArgs)).events }; +} + +function createDeferredConversationProgressObserver(...args) { + const observer = createStructuralDeferredObserver(...args); + return { ...observer, observe: async (...observeArgs) => (await observer.observe(...observeArgs)).events }; +} + +test('returns fixed structural compatibility outcomes without retaining rejected frame data', async () => { + const workspace = await mkdtemp(join(tmpdir(), 'zcode-progress-')); + const fresh = () => createStructuralDescriber({ sessionId: 'session-1', subscriptionId: 'sub-1', workspace }); + const acceptedInitial = await (await fresh()).observe(conversationFrame({ deliveryKind: 'initial', deltas: [] }), observedAt); + assert.deepEqual(acceptedInitial, { disposition: 'accepted', phase: 'initial', events: [] }); + const acceptedOnline = await (await fresh()).observe(conversationFrame({ deliveryKind: 'online', deltas: [] }), observedAt); + assert.deepEqual(acceptedOnline, { disposition: 'accepted', phase: 'online', events: [] }); + const recoveryDescriber = await fresh(); recoveryDescriber.markGap(); + const acceptedRecovery = await recoveryDescriber.observe(conversationFrame({ deliveryKind: 'recovery', deltas: [] }), observedAt); + assert.deepEqual(acceptedRecovery, { disposition: 'accepted', phase: 'recovery', events: [] }); + + const cases = [ + ['wire-version', (frame) => { frame.params.wireVersion = 99; }], + ['envelope-shape', (frame) => { frame.params.hostile = 'ENVELOPE_SECRET'; }], + ['sequence', (frame) => { frame.params.frame.fromSeq = -1; }], + ['topic', (frame) => { frame.params.topic = 'conversation/TOPIC_SECRET'; }], + ['row-kind', (frame) => { frame.params.frame.payload.deltas[0].row.kind = 'SECRET_KIND'; }], + ['row-shape', (frame) => { frame.params.frame.payload.deltas[0].row.hostile = 'ROW_SECRET'; }], + ]; + for (const [reason, mutate] of cases) { + const frame = conversationFrame({ deltas: [toolRow({ input: { command: 'COMMAND_SECRET' } })] }); mutate(frame); + const result = await (await fresh()).observe(frame, observedAt); + assert.deepEqual(result, { disposition: 'rejected', reason, events: [] }); + assert.doesNotMatch(JSON.stringify(result), /SECRET/); + } +}); + test('normalizes previews by removing controls, collapsing whitespace, and truncating by Unicode code point', () => { assert.equal(normalizePreview(' a\r\n\tb\u0000\u0085 c ', 96), 'a b c'); const value = `${'😀'.repeat(95)}界尾`; @@ -162,20 +199,20 @@ test('fails closed on every missing extra mistyped controlled or unverified capt assert.deepEqual(await turnDescriber.observe(hostileTurn, observedAt), []); }); -test('uses continuous captured sequence and ordinal watermarks without invalid frames poisoning them', async () => { +test('rejects received discontinuities without poisoning watermarks and requires a newer recovery baseline', async () => { const workspace = await mkdtemp(join(tmpdir(), 'zcode-progress-')); - const describer = await createConversationProgressDescriber({ sessionId: 'session-1', subscriptionId: 'sub-1', workspace }); + const describer = await createStructuralDescriber({ sessionId: 'session-1', subscriptionId: 'sub-1', workspace }); const frame = (ordinal, fromSeq, toSeq, rowId) => conversationFrame({ ordinal, fromSeq, toSeq, deltas: [toolRow({ rowId, input: { command: `echo ${rowId}` } })] }); - assert.equal((await describer.observe(frame(7, 10, 10, 1), observedAt)).length, 1); - assert.deepEqual(await describer.observe(frame(8, 1, 11, 2), observedAt), []); - assert.deepEqual(await describer.observe(frame(8, 12, 12, 2), observedAt), []); - assert.deepEqual(await describer.observe(frame(8, 11, 10, 2), observedAt), []); + assert.equal((await describer.observe(frame(7, 10, 10, 1), observedAt)).events.length, 1); + assert.deepEqual(await describer.observe(frame(8, 1, 11, 2), observedAt), { disposition: 'rejected', reason: 'sequence', events: [] }); + assert.deepEqual(await describer.observe(frame(8, 11, 11, 2), observedAt), { disposition: 'ignored', reason: 'recovery-required', events: [] }); const invalid = frame(8, 11, 11, 2); invalid.params.frame.payload.deltas[0].row.extra = true; - assert.deepEqual(await describer.observe(invalid, observedAt), []); - assert.equal((await describer.observe(frame(8, 11, 11, 2), observedAt)).length, 1); - assert.deepEqual(await describer.observe(frame(8, 11, 11, 3), observedAt), []); - assert.deepEqual(await describer.observe(frame(10, 12, 12, 3), observedAt), []); - assert.equal((await describer.observe(frame(9, 12, 12, 3), observedAt)).length, 1); + assert.deepEqual(await describer.observe(invalid, observedAt), { disposition: 'rejected', reason: 'row-shape', events: [] }); + assert.deepEqual(await describer.observe(conversationFrame({ ordinal: 8, fromSeq: 11, toSeq: 11, deliveryKind: 'recovery', deltas: [] }), observedAt), { disposition: 'accepted', phase: 'recovery', events: [] }); + assert.deepEqual(await describer.observe(frame(8, 11, 11, 3), observedAt), { disposition: 'rejected', reason: 'sequence', events: [] }); + assert.deepEqual(await describer.observe(frame(10, 12, 12, 3), observedAt), { disposition: 'rejected', reason: 'sequence', events: [] }); + assert.deepEqual(await describer.observe(conversationFrame({ ordinal: 9, fromSeq: 12, toSeq: 12, deliveryKind: 'recovery', deltas: [] }), observedAt), { disposition: 'accepted', phase: 'recovery', events: [] }); + assert.equal((await describer.observe(frame(10, 13, 13, 3), observedAt)).events.length, 1); }); test('recognizes only captured tool failure statuses and turn failure terminal states', async () => { @@ -292,6 +329,25 @@ test('recovery silently folds bounded tool states without path resolution and de assert.equal(terminal.length, 1); assert.doesNotMatch(JSON.stringify(terminal), /RECOVERY_PATH_SECRET/); }); +test('received sequence gaps reject visibly until a newer recovery baseline restores flow', async () => { + const workspace = await mkdtemp(join(tmpdir(), 'zcode-progress-')); + const describer = await createStructuralDescriber({ sessionId: 'session-1', subscriptionId: 'sub-1', workspace }); + assert.deepEqual(await describer.observe(conversationFrame({ ordinal: 1, deliveryKind: 'recovery', deltas: [] }), observedAt), { + disposition: 'accepted', phase: 'recovery', events: [], + }); + for (const ordinal of [3, 4, 5, 6]) { + assert.deepEqual(await describer.observe(conversationFrame({ ordinal, deltas: [] }), observedAt), { + disposition: 'rejected', reason: 'sequence', events: [], + }); + } + assert.deepEqual(await describer.observe(conversationFrame({ ordinal: 7, deliveryKind: 'recovery', deltas: [] }), observedAt), { + disposition: 'accepted', phase: 'recovery', events: [], + }); + const resumed = await describer.observe(conversationFrame({ ordinal: 8, deltas: [toolRow({ rowId: 8 })] }), observedAt); + assert.equal(resumed.disposition, 'accepted'); assert.equal(resumed.phase, 'online'); assert.equal(resumed.events.length, 1); + assert.doesNotMatch(JSON.stringify(resumed), /frame-|tool-8|turn-1/); +}); + test('accepts bounded captured multiline tool errors without rendering raw error content', async () => { const workspace = await mkdtemp(join(tmpdir(), 'zcode-progress-')); const describer = await createConversationProgressDescriber({ sessionId: 'session-1', subscriptionId: 'sub-1', workspace }); @@ -318,6 +374,49 @@ test('bounds the prebind subscribe-response buffer to four notifications and dra assert.deepEqual(await deferred.observe(conversationFrame({ ordinal: 5, deltas: [toolRow({ rowId: 5 })] }), observedAt), []); }); +test('prebind overflow requires and accepts a newer recovery baseline', async () => { + const workspace = await mkdtemp(join(tmpdir(), 'zcode-progress-')); + const deferred = createStructuralDeferredObserver({ sessionId: 'session-1', workspace }); + const buffered = Array.from({ length: 4 }, (_, index) => deferred.observe(conversationFrame({ ordinal: index + 1, deltas: [toolRow({ rowId: index + 1 })] }), observedAt)); + assert.deepEqual(await deferred.observe(conversationFrame({ ordinal: 5, deltas: [toolRow({ rowId: 5 })] }), observedAt), { disposition: 'ignored', reason: 'overflow', events: [] }); + await deferred.bind('sub-1'); await Promise.all(buffered); + assert.deepEqual(await deferred.observe(conversationFrame({ ordinal: 5, deltas: [toolRow({ rowId: 5 })] }), observedAt), { disposition: 'ignored', reason: 'recovery-required', events: [] }); + assert.deepEqual(await deferred.observe(conversationFrame({ ordinal: 6, deltas: [toolRow({ rowId: 6 })] }), observedAt), { disposition: 'rejected', reason: 'sequence', events: [] }); + assert.deepEqual(await deferred.observe(conversationFrame({ ordinal: 7, deliveryKind: 'recovery', deltas: [] }), observedAt), { disposition: 'accepted', phase: 'recovery', events: [] }); + const resumed = await deferred.observe(conversationFrame({ ordinal: 8, deltas: [toolRow({ rowId: 8 })] }), observedAt); + assert.equal(resumed.disposition, 'accepted'); assert.equal(resumed.phase, 'online'); assert.equal(resumed.events.length, 1); +}); + +test('overflow during bind latches recovery before a newly buffered recovery frame drains', async () => { + const workspace = await mkdtemp(join(tmpdir(), 'zcode-progress-')); + const deferred = createStructuralDeferredObserver({ sessionId: 'session-1', workspace }); + const buffered = Array.from({ length: 4 }, (_, index) => deferred.observe(conversationFrame({ ordinal: index + 1, deltas: [toolRow({ rowId: index + 1 })] }), observedAt)); + let overflow; let recovery; + const injectOverflow = buffered[0].then(() => { + const capacityGate = deferred.observe(conversationFrame({ ordinal: 5, deltas: [toolRow({ rowId: 5 })] }), observedAt); + deferred.observe(conversationFrame({ ordinal: 6, deltas: [toolRow({ rowId: 6 })] }), observedAt); + overflow = deferred.observe(conversationFrame({ ordinal: 7, deltas: [toolRow({ rowId: 7, input: { command: 'PRIVATE_BIND_OVERFLOW' } })] }), observedAt); + return capacityGate.then(() => { recovery = deferred.observe(conversationFrame({ ordinal: 8, deliveryKind: 'recovery', deltas: [] }), observedAt); }); + }); + await deferred.bind('sub-1'); await injectOverflow; + const overflowResult = await overflow; + assert.deepEqual(overflowResult, { disposition: 'ignored', reason: 'overflow', events: [] }); + assert.doesNotMatch(JSON.stringify(overflowResult), /PRIVATE_BIND_OVERFLOW/); + assert.deepEqual(await recovery, { disposition: 'accepted', phase: 'recovery', events: [] }); + const resumed = await deferred.observe(conversationFrame({ ordinal: 9, deltas: [toolRow({ rowId: 9 })] }), observedAt); + assert.equal(resumed.disposition, 'accepted'); assert.equal(resumed.phase, 'online'); assert.equal(resumed.events.length, 1); +}); + +test('prebind markGap resolves buffered observations as recovery-required', async () => { + const workspace = await mkdtemp(join(tmpdir(), 'zcode-progress-')); + const deferred = createStructuralDeferredObserver({ sessionId: 'session-1', workspace }); + const buffered = deferred.observe(conversationFrame({ deltas: [toolRow()] }), observedAt); + deferred.markGap(); + assert.deepEqual(await buffered, { disposition: 'ignored', reason: 'recovery-required', events: [] }); + await deferred.bind('sub-1'); + assert.deepEqual(await deferred.observe(conversationFrame({ ordinal: 2, deliveryKind: 'recovery', deltas: [] }), observedAt), { disposition: 'accepted', phase: 'recovery', events: [] }); +}); + test('deferred observer can fence and recover more than one post-bind overflow episode', async () => { const workspace = await mkdtemp(join(tmpdir(), 'zcode-progress-')); const deferred = createDeferredConversationProgressObserver({ sessionId: 'session-1', workspace }); await deferred.bind('sub-1'); diff --git a/tests/e2e/codex-skills-e2e.test.mjs b/tests/e2e/codex-skills-e2e.test.mjs index c9bbd2ab..ad3ce3b8 100644 --- a/tests/e2e/codex-skills-e2e.test.mjs +++ b/tests/e2e/codex-skills-e2e.test.mjs @@ -1,6 +1,7 @@ // @ts-nocheck import assert from 'node:assert/strict'; import { spawn } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; import { chmod, cp, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rename, rm, stat, symlink, unlink, writeFile } from 'node:fs/promises'; import { homedir, tmpdir } from 'node:os'; import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path'; @@ -23,11 +24,158 @@ import { const root = fileURLToPath(new URL('../..', import.meta.url)); const fakeZCode = fileURLToPath(new URL('../fixtures/fake-zcode-cli.mjs', import.meta.url)); +const TEST_PROCESS_NONCE = 'a'.repeat(64); +const STALE_PROCESS_NONCE = 'b'.repeat(64); const SUPPORTED_CODEX_LINES = Object.freeze(['0.147']); const qualificationRequired = process.env.ZCODE_REQUIRE_QUALIFIED === '1'; const optInSkip = process.env.ZCODE_CODEX_SKILLS_E2E === '1' || qualificationRequired ? false : unqualified('opt-in-required', 'Set ZCODE_CODEX_SKILLS_E2E=1 to spend authenticated Codex credits.'); const rescueOptInSkip = process.env.ZCODE_CODEX_RESCUE_E2E === '1' || qualificationRequired ? false : unqualified('opt-in-required', 'Set ZCODE_CODEX_RESCUE_E2E=1 to qualify the runtime-observed native Rescue route.'); +test('foreground Rescue gate lifecycle releases and cleans exact processes on gate discovery timeout', async () => { + const events = []; let rejectCodex; let alive = true; + const result = new Promise((_, reject) => { rejectCodex = reject; }); + await assert.rejects(runHeldForegroundRescue({ + gatePath: '/exact/gate', processPath: '/exact/process.json', processNonce: TEST_PROCESS_NONCE, + launch: () => ({ result, terminate: async () => { events.push('terminate-codex'); rejectCodex(new Error('terminated exact Codex')); throw new Error('Codex termination reported failure'); } }), + waitForGate: async () => { throw new Error('gate discovery timeout'); }, + readProcessMarker: async () => ({ pid: 48123, ppid: 71, nonce: TEST_PROCESS_NONCE }), inspectProcessIdentity: async () => alive ? ({ pid: 48123, ppid: 71, startIdentity: 'start-a', processNonce: TEST_PROCESS_NONCE }) : undefined, + releaseGate: async (path) => { events.push(`release:${path}`); }, terminateExactProcess: async (identity) => { events.push(`terminate-pid:${identity.pid}`); alive = false; }, + waitForProcessExit: async (identity) => { events.push(`wait-pid:${identity.pid}`); }, holdMs: 0, + }), /gate discovery timeout/); + assert.deepEqual(events, ['release:/exact/gate', 'terminate-pid:48123', 'wait-pid:48123', 'terminate-codex']); +}); + +test('foreground Rescue gate lifecycle consumes Codex failure and releases the exact gate', async () => { + const events = []; let alive = true; + await assert.rejects(runHeldForegroundRescue({ + gatePath: '/exact/gate', processPath: '/exact/process.json', processNonce: TEST_PROCESS_NONCE, + launch: () => ({ result: Promise.reject(new Error('Codex failed')), terminate: async () => { events.push('terminate-codex'); } }), + waitForGate: (signal) => new Promise((resolvePromise) => signal.addEventListener('abort', resolvePromise, { once: true })), + readProcessMarker: async () => ({ pid: 59123, ppid: 72, nonce: TEST_PROCESS_NONCE }), inspectProcessIdentity: async () => alive ? ({ pid: 59123, ppid: 72, startIdentity: 'start-a', processNonce: TEST_PROCESS_NONCE }) : undefined, + releaseGate: async (path) => { events.push(`release:${path}`); }, terminateExactProcess: async (identity) => { events.push(`terminate-pid:${identity.pid}`); alive = false; }, + waitForProcessExit: async (identity) => { events.push(`wait-pid:${identity.pid}`); }, holdMs: 0, + }), /Codex failed/); + assert.deepEqual(events, ['release:/exact/gate', 'terminate-pid:59123', 'wait-pid:59123', 'terminate-codex']); +}); + +test('foreground Rescue gate lifecycle cleans an exact fake child that does not exit naturally', async () => { + const events = []; let gateReleased = false; let alive = true; let resolveCodex; + const result = new Promise((resolvePromise) => { resolveCodex = resolvePromise; }); + await assert.rejects(runHeldForegroundRescue({ + gatePath: '/exact/gate', processPath: '/exact/process.json', processNonce: TEST_PROCESS_NONCE, launch: () => ({ result }), + waitForGate: async () => {}, readProcessMarker: async () => ({ pid: 60123, ppid: 73, nonce: TEST_PROCESS_NONCE }), inspectProcessIdentity: async () => alive ? ({ pid: 60123, ppid: 73, startIdentity: 'start-a', processNonce: TEST_PROCESS_NONCE }) : undefined, + releaseGate: async () => { gateReleased = true; events.push('release'); resolveCodex({ code: 0, stdout: '', stderr: '' }); }, sleep: async () => {}, + waitForProcessExit: async (identity, phase) => { events.push(`wait-${phase}:${identity.pid}`); if (phase === 'natural') throw new Error('fake child did not exit'); }, + terminateExactProcess: async (identity) => { assert.equal(gateReleased, true); events.push(`terminate-pid:${identity.pid}`); alive = false; }, holdMs: 0, + }), /fake child did not exit/); + assert.deepEqual(events, ['release', 'wait-natural:60123', 'terminate-pid:60123', 'wait-cleanup:60123']); +}); + +test('foreground Rescue gate lifecycle releases the exact gate when launch throws synchronously', async () => { + const released = []; + await assert.rejects(runHeldForegroundRescue({ + gatePath: '/exact/gate', processPath: '/exact/process.json', processNonce: TEST_PROCESS_NONCE, + launch: () => { throw new Error('synchronous launch failure'); }, + waitForGate: async () => { assert.fail('gate wait must not start before launch succeeds'); }, + releaseGate: async (path) => { released.push(path); }, holdMs: 0, + }), /synchronous launch failure/); + assert.deepEqual(released, ['/exact/gate']); +}); + +test('foreground Rescue early Codex result aborts and awaits the losing gate wait', async () => { + let aborted = false; let awaited = false; const events = []; + const foreground = await runHeldForegroundRescue({ + gatePath: '/exact/gate', processPath: '/exact/process.json', processNonce: TEST_PROCESS_NONCE, + launch: () => ({ result: Promise.resolve({ code: 0, stdout: '', stderr: '' }), terminate: async () => { events.push('terminate'); } }), + waitForGate: (signal) => new Promise((resolvePromise) => signal.addEventListener('abort', () => { + aborted = true; queueMicrotask(() => { awaited = true; resolvePromise(); }); + }, { once: true })), + readProcessMarker: async () => { const error = new Error('marker absent'); error.code = 'ENOENT'; throw error; }, releaseGate: async () => { events.push('release'); }, holdMs: 0, + }); + assert.equal(foreground.endedBeforeGate, true); + assert.equal(aborted, true); assert.equal(awaited, true); + assert.deepEqual(events, ['release', 'terminate']); +}); + +test('foreground Rescue refuses to signal stale, nonce-mismatched, or reparented process identities', async (t) => { + const cases = [ + { + name: 'stale marker nonce', marker: { pid: 61123, ppid: 71, nonce: STALE_PROCESS_NONCE }, + inspect: async () => ({ pid: 61123, ppid: 71, startIdentity: 'start-a', processNonce: TEST_PROCESS_NONCE }), pattern: /nonce changed|nonce mismatch/i, + }, + { + name: 'reparented process', marker: { pid: 61124, ppid: 72, nonce: TEST_PROCESS_NONCE }, + inspect: async () => ({ pid: 61124, ppid: 73, startIdentity: 'start-a', processNonce: TEST_PROCESS_NONCE }), pattern: /parent identity changed|parent.*mismatch/i, + }, + { + name: 'same-second PID reuse', marker: { pid: 61125, ppid: 74, nonce: TEST_PROCESS_NONCE }, + inspect: (() => { let reads = 0; return async () => ({ pid: 61125, ppid: 74, startIdentity: 'same-second', processNonce: reads++ === 0 ? TEST_PROCESS_NONCE : STALE_PROCESS_NONCE }); })(), + failAfterCapture: true, pattern: /process identity changed/i, + }, + ]; + for (const scenario of cases) await t.test(scenario.name, async () => { + let terminated = false; let codexTerminated = false; let rejectCodex; + const result = new Promise((_, reject) => { rejectCodex = reject; }); + await assert.rejects(runHeldForegroundRescue({ + gatePath: '/exact/gate', processPath: '/exact/process.json', processNonce: TEST_PROCESS_NONCE, + launch: () => ({ result, terminate: async () => { codexTerminated = true; rejectCodex(new Error('terminated exact Codex')); } }), waitForGate: async () => {}, + readProcessMarker: async () => scenario.marker, inspectProcessIdentity: scenario.inspect, + releaseGate: async () => {}, sleep: async () => { if (scenario.failAfterCapture) throw new Error('verify cleanup identity'); }, + terminateExactProcess: async () => { terminated = true; }, waitForProcessExit: async () => {}, holdMs: 0, + }), scenario.pattern); + assert.equal(terminated, false, 'identity mismatch must never signal an unrelated PID'); + assert.equal(codexTerminated, true, 'identity mismatch must not prevent exact Codex termination'); + }); +}); + +test('foreground Rescue reports cleanup identity change without signaling the reused PID', async () => { + let reads = 0; let terminated = false; let rejectCodex; + const result = new Promise((_, reject) => { rejectCodex = reject; }); + await assert.rejects(runHeldForegroundRescue({ + gatePath: '/exact/gate', processPath: '/exact/process.json', processNonce: TEST_PROCESS_NONCE, + launch: () => ({ result, terminate: async () => { rejectCodex(new Error('terminated exact Codex')); } }), waitForGate: async () => {}, + readProcessMarker: async () => ({ pid: 62123, ppid: 81, nonce: TEST_PROCESS_NONCE }), + inspectProcessIdentity: async () => ({ pid: 62123, ppid: 81, startIdentity: reads++ === 0 ? 'start-a' : 'start-b', processNonce: TEST_PROCESS_NONCE }), + releaseGate: async () => {}, sleep: async () => { throw new Error('held verification failed'); }, + terminateExactProcess: async () => { terminated = true; }, holdMs: 0, + }), /process identity changed during cleanup/i); + assert.equal(terminated, false); +}); + +test('foreground Rescue cleans the exact fake child before Codex termination can reparent it', async () => { + const events = []; let alive = true; let reparented = false; let rejectCodex; + const result = new Promise((_, reject) => { rejectCodex = reject; }); + await assert.rejects(runHeldForegroundRescue({ + gatePath: '/exact/gate', processPath: '/exact/process.json', processNonce: TEST_PROCESS_NONCE, + launch: () => ({ result, terminate: async () => { events.push('terminate-codex'); reparented = true; rejectCodex(new Error('terminated exact Codex')); } }), + waitForGate: async () => {}, + readProcessMarker: async () => ({ pid: 63123, ppid: 91, nonce: TEST_PROCESS_NONCE }), + inspectProcessIdentity: async () => alive ? ({ pid: 63123, ppid: reparented ? 1 : 91, startIdentity: 'start-a', processNonce: TEST_PROCESS_NONCE }) : undefined, + releaseGate: async () => { events.push('release'); }, + terminateExactProcess: async () => { events.push('terminate-child'); alive = false; }, + waitForProcessExit: async (_identity, phase) => { events.push(`wait-child:${phase}`); assert.equal(alive, false); }, + sleep: async () => { throw new Error('held verification failed'); }, holdMs: 0, + }), /held verification failed/); + assert.deepEqual(events, ['release', 'terminate-child', 'wait-child:cleanup', 'terminate-codex']); + assert.equal(alive, false); +}); + +test('foreground Rescue still terminates Codex after exact child cleanup fails', async () => { + const events = []; let rejectCodex; + const result = new Promise((_, reject) => { rejectCodex = reject; }); + await assert.rejects(runHeldForegroundRescue({ + gatePath: '/exact/gate', processPath: '/exact/process.json', processNonce: TEST_PROCESS_NONCE, + launch: () => ({ result, terminate: async () => { events.push('terminate-codex'); rejectCodex(new Error('terminated exact Codex')); } }), + waitForGate: async () => {}, + readProcessMarker: async () => ({ pid: 63124, ppid: 92, nonce: TEST_PROCESS_NONCE }), + inspectProcessIdentity: async () => ({ pid: 63124, ppid: 92, startIdentity: 'start-a', processNonce: TEST_PROCESS_NONCE }), releaseGate: async () => { events.push('release'); }, + terminateExactProcess: async () => { events.push('terminate-child'); throw new Error('exact child cleanup failed'); }, + waitForProcessExit: async () => { events.push('wait-child'); throw new Error('exact child remained alive'); }, + sleep: async () => { throw new Error('held verification failed'); }, holdMs: 0, + }), /held verification failed/); + assert.deepEqual(events, ['release', 'terminate-child', 'wait-child', 'terminate-codex']); +}); + test('preserved installed evidence scrubs isolated credential copies on normal, thrown, and timed-out cleanup', async (t) => { for (const mode of ['normal', 'sync-throw', 'timeout']) await t.test(mode, async () => { const temporary = await mkdtemp(join(tmpdir(), 'zcode-installed-evidence-cleanup-')); t.after(() => rm(temporary, { recursive: true, force: true })); @@ -133,15 +281,28 @@ test('installed Rescue uses one isolated native child for initial and choice con } assert.equal(setupReady, true, 'four successful setup turns did not establish a fresh-session ready Rescue Role'); await qualifyInstalledIdentityFailures({ installedPluginRoot, installedDataRoot: join(codexHome, 'plugins', 'data', 'zcode-vitry'), temporary, env, zcodeRecord }); - await writeFile(zcodeRecord, ''); - const rescue = await codex([...commonArgs, 'Use the installed $zcode:rescue --fresh --wait skill exactly once now. Require ZCode to run exactly `npm test` as the safe deterministic fixture action, then return only its final public result.'], workspace, env, 240_000); + const foregroundGate = join(temporary, 'foreground-long-completion.gate'); const foregroundGateReached = join(temporary, 'foreground-long-completion.reached'); const foregroundProcess = join(temporary, 'foreground-long-process.json'); const foregroundNonce = randomBytes(32).toString('hex'); + await Promise.all([writeFile(zcodeRecord, ''), writeFile(foregroundGate, 'hold'), writeFile(foregroundGateReached, ''), writeFile(foregroundProcess, '')]); + const longEnv = { ...env, FAKE_ZCODE_COMPLETION_GATE: foregroundGate, FAKE_ZCODE_COMPLETION_GATE_REACHED: foregroundGateReached, FAKE_ZCODE_PROCESS_FILE: foregroundProcess, FAKE_ZCODE_PROCESS_NONCE: foregroundNonce }; + const foreground = await runHeldForegroundRescue({ + gatePath: foregroundGate, processPath: foregroundProcess, processNonce: foregroundNonce, + launch: () => controlledCodex([...commonArgs, 'Use the installed $zcode:rescue --fresh --wait skill exactly once now. Require ZCode to run exactly `npm test` as the safe deterministic fixture action, then return only its final public result.'], workspace, longEnv, 300_000), + waitForGate: (signal) => waitUntil(async () => await readFile(foregroundGateReached, 'utf8').catch(() => '') === 'blocked', 60_000, 'installed foreground Rescue never reached the held fake-ZCode completion boundary', signal), + holdMs: 35_000, + }); + if (foreground.endedBeforeGate) { + if (skipExternalFailure(t, foreground.rescue)) return; + assert.fail(`installed foreground Rescue ended before the long completion boundary\n${foreground.rescue.stdout}\n${foreground.rescue.stderr}`); + } + const { rescue, processAliveWhileHeld } = foreground; if (skipExternalFailure(t, rescue)) return; assert.equal(rescue.code, 0, `codex Rescue failed\n${rescue.stdout}\n${rescue.stderr}`); + assert.equal(processAliveWhileHeld, true, 'the exact fake-ZCode process must remain alive beyond the maximum initial host yield'); const frames = rescue.stdout.trim().split('\n').filter(Boolean).map((line) => JSON.parse(line)); const expectedCommand = `node "${installedPluginRoot}/scripts/zcode-companion.mjs" invoke rescue`; const expectedPreflightCommand = `node "${installedPluginRoot}/scripts/zcode-companion.mjs" role-status rescue`; const expectedNamedSpawnMessage = 'Run the installed ZCode Rescue forwarder now. Return its public stdout verbatim.'; - const expectedGenericSpawnMessage = `Act only as the installed ZCode Rescue forwarder. In the current workspace run exactly:\n${expectedCommand}\nPreserve stderr and return public stdout verbatim. Do not inspect or modify code independently, interpret results, retry, poll, cancel, choose a pending branch, or request/print/persist authorization material.\nIf that command returned a needs-choice response, stop. Only after the parent sends exactly \`Continue the pending ZCode Rescue with resume. Run only the installed resume forwarder command and return its public stdout verbatim.\` run exactly:\nnode "${installedPluginRoot}/scripts/zcode-companion.mjs" invoke-choice rescue resume\nOnly after the parent sends exactly \`Continue the pending ZCode Rescue with fresh. Run only the installed fresh forwarder command and return its public stdout verbatim.\` run exactly:\nnode "${installedPluginRoot}/scripts/zcode-companion.mjs" invoke-choice rescue fresh`; + const expectedGenericSpawnMessage = `Act only as the installed ZCode Rescue forwarder. In the current workspace run exactly:\n${expectedCommand}\nPreserve stderr and return public stdout verbatim. Do not inspect or modify code independently, interpret results, retry, cancel, choose a pending branch, or request/print/persist authorization material.\nHere exactly one command means exactly one \`exec_command\` companion process; continuation calls only observe its original running handle. Never start a second \`exec_command\`. A companion result containing an exit code is terminal. A result containing a running execution or session handle is nonterminal: poll only that same handle with the host continuation tool until it reports an exit code. Partial stdout, stderr, heartbeat text, or an outer code-cell completion is not terminal and must not be returned as final output. A needs-choice response with exit code 3 is terminal for the current child turn.\nIf that command returned a needs-choice response, stop. Only after the parent sends exactly \`Continue the pending ZCode Rescue with resume. Run only the installed resume forwarder command and return its public stdout verbatim.\` run exactly:\nnode "${installedPluginRoot}/scripts/zcode-companion.mjs" invoke-choice rescue resume\nOnly after the parent sends exactly \`Continue the pending ZCode Rescue with fresh. Run only the installed fresh forwarder command and return its public stdout verbatim.\` run exactly:\nnode "${installedPluginRoot}/scripts/zcode-companion.mjs" invoke-choice rescue fresh`; const canonicalWorkspace = await realpath(workspace); const zcodeCalls = (await readFile(zcodeRecord, 'utf8')).trim().split('\n').filter(Boolean).map(JSON.parse); assert.equal(zcodeCalls.filter((call) => call.method === 'session/send').length, 1, 'one ZCode send after one native child spawn'); @@ -158,13 +319,18 @@ test('installed Rescue uses one isolated native child for initial and choice con expectedNamedSpawnMessage, expectedGenericSpawnMessage, expectedPublicOutput: 'ZCODE_RESCUE_PUBLIC_SENTINEL_7C9C', + requireYieldedExecution: true, expectedSemanticProgress: { start: '[zcode] Running command: npm test.', terminal: '[zcode] Command completed: npm test (25ms).', + snapshotFallback: '[zcode] ZCode conversation frames were unavailable; using bounded session progress.', + lifecycleOnly: '[zcode] ZCode semantic progress is unavailable; lifecycle updates will continue.', }, forbiddenParentText: [ 'Running command: npm test.', 'Command completed: npm test (25ms).', + 'ZCode conversation frames were unavailable; using bounded session progress.', + 'ZCode semantic progress is unavailable; lifecycle updates will continue.', 'raw output must stay private', 'reasoning must stay private', 'capability must stay private', @@ -174,10 +340,18 @@ test('installed Rescue uses one isolated native child for initial and choice con ); assert.ok(['named', 'generic-schema-hidden'].includes(evidence.route), 'qualification must record an automatically observed native route'); assert.equal(evidence.semanticProgressChecked, true); + assert.equal(evidence.yieldedExecution.execCommandCount, 1); + assert.ok(evidence.yieldedExecution.pollCount >= 1); + assert.equal(evidence.yieldedExecution.sameHandleChecked, true); + assert.equal(evidence.yieldedExecution.terminalExitCode, 0); t.diagnostic(`qualified native Rescue route: ${evidence.route}`); } catch (error) { if (error instanceof CodexRescueUnqualifiedError && error.code === 'spawn-message-encrypted') { assert.ok(['named', 'generic-schema-hidden'].includes(error.evidence?.route), 'encrypted-message evidence must record the automatically observed native route'); + assert.equal(error.evidence.yieldedExecution.execCommandCount, 1); + assert.ok(error.evidence.yieldedExecution.pollCount >= 1); + assert.equal(error.evidence.yieldedExecution.sameHandleChecked, true); + assert.equal(error.evidence.yieldedExecution.terminalExitCode, 0); const detail = `Observed route ${error.evidence.route}. ${error.message}`; markUnqualified(t, unqualified(error.code, detail)); return; } @@ -218,16 +392,23 @@ test('installed Rescue uses one isolated native child for initial and choice con expectedFollowupMessage: followupMessage, expectedPreflightCommand, expectedPublicOutput: 'ZCODE_RESCUE_PUBLIC_SENTINEL_7C9C', + includeExecutionFacts: true, forbiddenParentText: [ 'Running command: npm test.', 'Command completed: npm test (25ms).', 'raw output must stay private', + 'ZCode conversation frames were unavailable; using bounded session progress.', + 'ZCode semantic progress is unavailable; lifecycle updates will continue.', 'reasoning must stay private', 'capability must stay private', 'v4/conversation/frame', ], }, ); assert.equal(evidence.choice, choice); + assert.equal(evidence.executions.initial.execCommandCount, 1); + assert.equal(evidence.executions.continuation.execCommandCount, 1); t.diagnostic(`qualified same-child Rescue ${choice}: ${evidence.childThreadId}`); } catch (error) { if (error instanceof CodexRescueUnqualifiedError && ['choice-followup-encrypted', 'choice-spawn-encrypted'].includes(error.code)) { + assert.equal(error.evidence.executions.initial.execCommandCount, 1); + assert.equal(error.evidence.executions.continuation.execCommandCount, 1); markUnqualified(t, unqualified(error.code, error.message)); return; } throw error; @@ -271,7 +452,7 @@ test('installed Rescue uses one isolated native child for initial and choice con expectedCommand, expectedPreflightCommand, expectedNamedSpawnMessage, expectedGenericSpawnMessage, privateExecutionCapability: privateCapability, publicLogs: [background.stdout, background.stderr], - forbiddenParentText: ['Running command: npm test.', 'Command completed: npm test (25ms).', 'raw output must stay private', 'reasoning must stay private', 'capability must stay private', 'v4/conversation/frame'], + forbiddenParentText: ['Running command: npm test.', 'Command completed: npm test (25ms).', 'ZCode conversation frames were unavailable; using bounded session progress.', 'ZCode semantic progress is unavailable; lifecycle updates will continue.', 'raw output must stay private', 'reasoning must stay private', 'capability must stay private', 'v4/conversation/frame'], }, ); assert.equal(evidence.jobId, backgroundJobId); assert.equal(evidence.capabilityChecked, true); assert.ok(['named', 'generic-schema-hidden'].includes(evidence.route)); @@ -410,11 +591,13 @@ test('installed Rescue uses one isolated native child for initial and choice con await writeFile(zcodeRecord, ''); const lossWorkspace = join(temporary, 'loss-workspace'); await initializeGitWorkspace(lossWorkspace); const lossCanonicalWorkspace = await realpath(lossWorkspace); const lossGate = join(temporary, 'loss-completion.gate'); const lossGateReached = join(temporary, 'loss-completion.reached'); + const lossProcessNonce = randomBytes(32).toString('hex'); const lossStorage = await resolveWorkspaceStorage({ dataRoot: installedDataRoot, workspace: lossCanonicalWorkspace }); const lossJobsDirectory = join(lossStorage.directory, 'jobs'); const lossBaseline = await canonicalJobIds(lossJobsDirectory); await Promise.all([writeFile(lossGate, 'hold'), writeFile(recoveryControl, JSON.stringify({ mode: 'active' }))]); let lossIdentity; let lossJobId; let lossJobPath; let lossThreadId; let lossVerified = false; const lossApp = await createInstalledCodexAppServer(lossWorkspace, { ...env, + ZCODE_E2E_PROCESS_NONCE: lossProcessNonce, FAKE_ZCODE_COMPLETION_GATE: lossGate, FAKE_ZCODE_COMPLETION_GATE_REACHED: lossGateReached, FAKE_ZCODE_COMPLETION_GATE_REACHED_DELAY_MS: '100', @@ -438,11 +621,12 @@ test('installed Rescue uses one isolated native child for initial and choice con const lossRoute = await waitForValue(async () => nativeRouteEvidence(await loadCodexRollouts(codexHome).catch(() => []), lossThreadId), 30_000, 'installed loss turn exposed no native child identity'); assert.equal(lossRoute.spawnCount, 1); assert.equal(lossRoute.startCount, 1); + const lossWorkerProcess = await captureExactPidIdentity(lossIdentity.pid, lossProcessNonce, 'ZCODE_E2E_PROCESS_NONCE'); + await signalExactProcess(lossWorkerProcess, (expected) => readExactPidIdentity(expected), 'SIGKILL'); + await waitUntil(() => !processAlive(lossIdentity.pid), 5_000, 'the exact accepted foreground worker survived simulated native child loss'); const lostCodexPid = lossApp.pid; await lossApp.close('SIGKILL'); assert.equal(processAlive(lostCodexPid), false, 'the exact installed Codex parent process must be gone before recovery'); - if (processAlive(lossIdentity.pid)) process.kill(lossIdentity.pid, 'SIGKILL'); - await waitUntil(() => !processAlive(lossIdentity.pid), 5_000, 'the exact accepted foreground worker survived simulated native child loss'); await writeFile(recoveryControl, JSON.stringify({ mode: 'completed' })); await writeFile(lossGate, 'release'); const recovered = await codex([ @@ -534,9 +718,200 @@ async function installPrivateCapabilityObserver(installedPluginRoot, temporary) } async function codex(args, cwd, env, timeoutMs = 60_000) { return runProcess(codexLaunch(args, { root, env }), { cwd, env, timeoutMs, maxOutputBytes: 16 * 1024 * 1024 }); } +function controlledCodex(args, cwd, env, timeoutMs = 60_000) { + const controller = new AbortController(); + const result = runProcess(codexLaunch(args, { root, env }), { cwd, env, timeoutMs, maxOutputBytes: 16 * 1024 * 1024, signal: controller.signal }); + return { result, terminate: async () => { controller.abort(); await result.catch(() => {}); } }; +} + +async function runHeldForegroundRescue(input) { + const releaseGate = input.releaseGate ?? ((path) => writeFile(path, 'release')); + const sleep = input.sleep ?? ((duration) => new Promise((resolvePromise) => setTimeout(resolvePromise, duration))); + const readProcessMarker = input.readProcessMarker ?? (async () => JSON.parse(await readFile(input.processPath, 'utf8'))); + const inspectProcessIdentity = input.inspectProcessIdentity ?? inspectExactProcessIdentity; + const captureProcessIdentity = input.captureProcessIdentity ?? (async () => captureExactProcessIdentity(await readProcessMarker(), input.processNonce, inspectProcessIdentity)); + const readProcessIdentity = input.readProcessIdentity ?? ((expected) => readExactProcessIdentity(expected, readProcessMarker, inspectProcessIdentity)); + const waitForProcessExit = input.waitForProcessExit ?? ((expected, phase) => waitForExactProcessExit(expected, readProcessIdentity, phase)); + const terminateExactProcess = input.terminateExactProcess ?? ((expected) => terminateVerifiedProcess(expected, readProcessIdentity)); + const gateController = new AbortController(); + let control; let resultOutcome; let gateOutcome; let identity; let gateReleased = false; let completed = false; let answer; let failure; + try { + control = await input.launch(); + resultOutcome = Promise.resolve(control.result).then( + (value) => ({ kind: 'result', value }), + (error) => ({ kind: 'error', error }), + ); + gateOutcome = Promise.resolve().then(() => input.waitForGate(gateController.signal)).then( + () => ({ kind: 'held' }), + (error) => ({ kind: 'gate-error', error }), + ); + const boundary = await Promise.race([gateOutcome, resultOutcome]); + if (boundary.kind === 'gate-error') throw boundary.error; + if (boundary.kind === 'error') throw boundary.error; + if (boundary.kind === 'result') { + gateController.abort(); await gateOutcome; + answer = { endedBeforeGate: true, rescue: boundary.value }; + } else { + identity = await captureProcessIdentity(); + await sleep(input.holdMs ?? 35_000); + const processAliveWhileHeld = await readProcessIdentity(identity) !== undefined; + await releaseGate(input.gatePath); gateReleased = true; + const outcome = await resultOutcome; + if (outcome.kind === 'error') throw outcome.error; + await waitForProcessExit(identity, 'natural'); + answer = { endedBeforeGate: false, identity, processAliveWhileHeld, rescue: outcome.value }; + completed = true; + } + } catch (error) { failure = error; } + const cleanupErrors = []; + gateController.abort(); + if (gateOutcome) { try { await gateOutcome; } catch (error) { cleanupErrors.push(error); } } + if (!gateReleased) { + try { await releaseGate(input.gatePath); gateReleased = true; } catch (error) { cleanupErrors.push(error); } + } + if (!completed) { + if (!identity) { + try { identity = await captureProcessIdentity(); } + catch (error) { if (!processMarkerUnavailable(error)) cleanupErrors.push(error); } + } + if (identity) { + let current; + try { current = await readProcessIdentity(identity); } catch (error) { cleanupErrors.push(error); } + if (current) { + try { await terminateExactProcess(identity); } catch (error) { cleanupErrors.push(error); } + try { await waitForProcessExit(identity, 'cleanup'); } catch (error) { cleanupErrors.push(error); } + } + } + if (typeof control?.terminate === 'function') { try { await control.terminate(); } catch (error) { cleanupErrors.push(error); } } + } + if (cleanupErrors.length > 0) { + if (!failure) failure = cleanupErrors.length === 1 ? cleanupErrors[0] : new AggregateError(cleanupErrors, 'foreground Rescue cleanup failed'); + else { + const identityChanged = cleanupErrors.some((error) => /exact fake-ZCode process identity changed/iu.test(error?.message ?? '')); + failure = new AggregateError([failure, ...cleanupErrors], identityChanged ? `${failure.message}; exact fake-ZCode process identity changed during cleanup` : failure.message); + } + } + if (failure) throw failure; + return answer; +} + +async function captureExactProcessIdentity(marker, expectedNonce, inspect) { + validateProcessNonce(expectedNonce); validateProcessMarker(marker, expectedNonce); + const observed = await inspect(marker.pid, expectedNonce, 'FAKE_ZCODE_PROCESS_NONCE'); + if (!observed) throw new Error('exact fake-ZCode process exited before identity capture'); + validateObservedProcess(observed, marker.pid, expectedNonce); + if (observed.ppid !== marker.ppid) throw new Error('exact fake-ZCode parent identity mismatch'); + return { pid: marker.pid, ppid: marker.ppid, nonce: expectedNonce, nonceVariable: 'FAKE_ZCODE_PROCESS_NONCE', startIdentity: observed.startIdentity }; +} + +async function captureExactPidIdentity(pid, expectedNonce, nonceVariable) { + validateProcessNonce(expectedNonce); + const observed = await inspectExactProcessIdentity(pid, expectedNonce, nonceVariable); + if (!observed) throw new Error('exact installed process exited before identity capture'); + validateObservedProcess(observed, pid, expectedNonce); + return { pid, ppid: observed.ppid, nonce: expectedNonce, nonceVariable, startIdentity: observed.startIdentity }; +} + +function processMarkerUnavailable(error) { return error?.code === 'ENOENT' || error instanceof SyntaxError; } + +async function readExactProcessIdentity(expected, readMarker, inspect) { + const marker = await readMarker(); validateProcessMarker(marker, expected.nonce); + if (marker.pid !== expected.pid || marker.ppid !== expected.ppid) throw new Error('exact fake-ZCode process identity changed'); + const observed = await inspect(expected.pid, expected.nonce, expected.nonceVariable); + if (!observed) return undefined; + validateObservedProcess(observed, expected.pid, expected.nonce); + if (observed.ppid !== expected.ppid || observed.startIdentity !== expected.startIdentity) throw new Error('exact fake-ZCode process identity changed'); + return observed; +} + +async function readExactPidIdentity(expected) { + const observed = await inspectExactProcessIdentity(expected.pid, expected.nonce, expected.nonceVariable); + if (!observed) return undefined; + validateObservedProcess(observed, expected.pid, expected.nonce); + if (observed.ppid !== expected.ppid || observed.startIdentity !== expected.startIdentity) throw new Error('exact installed process identity changed'); + return observed; +} + +function validateProcessNonce(nonce) { + if (typeof nonce !== 'string' || !/^[a-f0-9]{64}$/u.test(nonce)) throw new Error('exact fake-ZCode process nonce mismatch'); +} + +function validateProcessMarker(marker, expectedNonce) { + validateProcessNonce(expectedNonce); + if (!marker || !Number.isSafeInteger(marker.pid) || marker.pid <= 0 || !Number.isSafeInteger(marker.ppid) || marker.ppid <= 0) throw new Error('exact fake-ZCode process marker identity is invalid'); + if (marker.nonce !== expectedNonce) throw new Error('exact fake-ZCode process nonce mismatch'); +} + +function validateObservedProcess(observed, expectedPid, expectedNonce) { + if (observed.pid !== expectedPid || !Number.isSafeInteger(observed.ppid) || observed.ppid <= 0) throw new Error('exact fake-ZCode process identity changed'); + if (typeof observed.startIdentity !== 'string' || observed.startIdentity.length === 0 || observed.startIdentity.length > 256) throw new Error('exact fake-ZCode process start identity is unavailable'); + if (observed.processNonce !== expectedNonce) throw new Error('exact fake-ZCode process identity changed'); +} + +async function inspectExactProcessIdentity(pid, expectedNonce, nonceVariable = 'FAKE_ZCODE_PROCESS_NONCE') { + if (!Number.isSafeInteger(pid) || pid <= 0) throw new Error('exact fake-ZCode PID is invalid'); + validateProcessNonce(expectedNonce); + if (typeof nonceVariable !== 'string' || !/^[A-Z][A-Z0-9_]{0,63}$/u.test(nonceVariable)) throw new Error('exact fake-ZCode process nonce marker is invalid'); + if (process.platform === 'win32') throw new Error('stable fake-ZCode process identity is unavailable on this platform'); + if (process.platform === 'linux') { + const before = await readLinuxProcessStart(pid); if (!before) return undefined; + let environ; + try { environ = await readFile(`/proc/${pid}/environ`, 'utf8'); } catch (error) { if (error?.code === 'ENOENT') return undefined; throw error; } + const after = await readLinuxProcessStart(pid); if (!after) return undefined; + if (before.ppid !== after.ppid || before.startIdentity !== after.startIdentity) throw new Error('exact fake-ZCode process identity changed'); + if (!environ.split('\0').includes(`${nonceVariable}=${expectedNonce}`)) throw new Error('exact fake-ZCode process identity changed'); + return { ...after, processNonce: expectedNonce }; + } + const before = await readPsProcessStart(pid); if (!before) return undefined; + const command = await runProcess({ command: 'ps', args: ['eww', '-o', 'command=', '-p', String(pid)] }, { timeoutMs: 2_000, maxOutputBytes: 64 * 1024 }); + if (command.code !== 0 || !command.stdout.trim()) return undefined; + const after = await readPsProcessStart(pid); if (!after) return undefined; + if (before.ppid !== after.ppid || before.startIdentity !== after.startIdentity) throw new Error('exact fake-ZCode process identity changed'); + const nonceMarker = `${nonceVariable}=${expectedNonce}`; + if (!command.stdout.split(/\s+/u).includes(nonceMarker)) throw new Error('exact fake-ZCode process identity changed'); + return { ...after, processNonce: expectedNonce }; +} + +async function readLinuxProcessStart(pid) { + let statText; + try { statText = await readFile(`/proc/${pid}/stat`, 'utf8'); } catch (error) { if (error?.code === 'ENOENT') return undefined; throw error; } + const close = statText.lastIndexOf(')'); const fields = close < 0 ? [] : statText.slice(close + 2).trim().split(/\s+/u); + const observedPid = Number(statText.slice(0, statText.indexOf(' '))); const ppid = Number(fields[1]); const startTicks = fields[19]; + if (observedPid !== pid || !Number.isSafeInteger(ppid) || ppid <= 0 || !/^\d+$/u.test(startTicks ?? '')) throw new Error('stable fake-ZCode process identity could not be parsed'); + return { pid: observedPid, ppid, startIdentity: `proc:${startTicks}` }; +} + +async function readPsProcessStart(pid) { + const result = await runProcess({ command: 'ps', args: ['-o', 'pid=', '-o', 'ppid=', '-o', 'lstart=', '-p', String(pid)] }, { timeoutMs: 2_000, maxOutputBytes: 4 * 1024 }); + if (result.code !== 0 || !result.stdout.trim()) return undefined; + const match = /^\s*(\d+)\s+(\d+)\s+(.+?)\s*$/u.exec(result.stdout); + if (!match || Number(match[1]) !== pid || !Number.isSafeInteger(Number(match[2])) || Number(match[2]) <= 0) throw new Error('stable fake-ZCode process identity could not be parsed'); + return { pid, ppid: Number(match[2]), startIdentity: match[3] }; +} + +async function terminateVerifiedProcess(expected, readIdentity) { + if (!await readIdentity(expected)) return; + try { process.kill(expected.pid, 'SIGTERM'); } catch { return; } + try { await waitForExactProcessExit(expected, readIdentity, 'terminate'); return; } catch { /* escalate only the still-matching PID */ } + if (!await readIdentity(expected)) return; + try { process.kill(expected.pid, 'SIGKILL'); } catch { /* already exited */ } +} + +async function signalExactProcess(expected, readIdentity, signal) { + if (!['SIGTERM', 'SIGKILL'].includes(signal)) throw new Error('exact installed process signal is invalid'); + if (!await readIdentity(expected)) return false; + try { process.kill(expected.pid, signal); return true; } catch { return false; } +} + +async function waitForExactProcessExit(expected, readIdentity, phase) { + const timeoutMs = phase === 'natural' ? 10_000 : phase === 'terminate' ? 1_000 : 5_000; + await waitUntil(async () => await readIdentity(expected) === undefined, timeoutMs, `the exact fake-ZCode process remained alive during ${phase}`); +} async function git(args, cwd) { const result = await runProcess({ command: 'git', args, options: { shell: false } }, { cwd, timeoutMs: 30_000 }); assert.equal(result.code, 0, result.stderr); return result; } async function initializeGitWorkspace(workspace) { await mkdir(workspace, { recursive: true }); await git(['init', '-q'], workspace); await writeFile(join(workspace, 'tracked.txt'), 'base\n'); await git(['add', 'tracked.txt'], workspace); await git(['-c', 'user.name=Test', '-c', 'user.email=test@example.com', 'commit', '-qm', 'base'], workspace); } -async function waitUntil(predicate, timeoutMs, message) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { if (await predicate()) return; await new Promise((resolve) => setTimeout(resolve, 50)); } assert.fail(message); } +async function waitUntil(predicate, timeoutMs, message, signal) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { if (signal?.aborted) throw abortError(); if (await predicate()) return; await abortableDelay(50, signal); } assert.fail(message); } +function abortableDelay(timeoutMs, signal) { return new Promise((resolvePromise, reject) => { if (signal?.aborted) { reject(abortError()); return; } const timer = setTimeout(finish, timeoutMs); const cancel = () => { clearTimeout(timer); signal?.removeEventListener('abort', cancel); reject(abortError()); }; function finish() { signal?.removeEventListener('abort', cancel); resolvePromise(); } signal?.addEventListener('abort', cancel, { once: true }); }); } +function abortError() { const error = new Error('foreground gate wait aborted'); error.name = 'AbortError'; return error; } async function waitForValue(read, timeoutMs, message) { let value; await waitUntil(async () => { value = await read(); return value !== undefined; }, timeoutMs, message); return value; } async function startInstalledTurn(app, threadId, text) { const result = await app.request('turn/start', { approvalPolicy: 'never', input: [{ type: 'text', text }], sandboxPolicy: { type: 'dangerFullAccess' }, threadId }, 30_000); assert.ok(result.turn?.id, 'installed turn/start omitted its turn ID'); return result.turn.id; } async function waitForCodexTurn(app, threadId, turnId, timeoutMs) { diff --git a/tests/fixtures/fake-zcode-cli.mjs b/tests/fixtures/fake-zcode-cli.mjs index 49484034..4418fa79 100644 --- a/tests/fixtures/fake-zcode-cli.mjs +++ b/tests/fixtures/fake-zcode-cli.mjs @@ -8,7 +8,16 @@ if (process.argv.includes('--version')) { process.stdout.write(`${process.env.FAKE_ZCODE_VERSION ?? '0.16.1'}\n`); process.exit(0); } -if (process.env.FAKE_ZCODE_PROCESS_FILE) await writeFile(process.env.FAKE_ZCODE_PROCESS_FILE, JSON.stringify({ pid: process.pid, ppid: process.ppid })); +const processNonce = process.env.FAKE_ZCODE_PROCESS_NONCE; +if (processNonce !== undefined && !/^[a-f0-9]{64}$/u.test(processNonce)) throw new Error('fake process nonce must be 256 bits'); +const progressDispatchGate = process.env.FAKE_ZCODE_PROGRESS_DISPATCH_GATE; +const progressDispatchGateNonce = process.env.FAKE_ZCODE_PROGRESS_DISPATCH_GATE_NONCE; +if ((progressDispatchGate === undefined) !== (progressDispatchGateNonce === undefined) + || progressDispatchGateNonce !== undefined && !/^[a-f0-9]{64}$/u.test(progressDispatchGateNonce)) throw new Error('fake progress-dispatch gate identity is invalid'); +let progressDispatchGateChecks = 0; +if (process.env.FAKE_ZCODE_PROCESS_FILE) await writeFile(process.env.FAKE_ZCODE_PROCESS_FILE, JSON.stringify({ + pid: process.pid, ppid: process.ppid, ...(processNonce ? { nonce: processNonce } : {}), +})); if (process.env.FAKE_ZCODE_STDERR_BYTES) process.stderr.write((process.env.FAKE_ZCODE_STDERR_TEXT ?? 'sensitive-stderr').repeat(Math.ceil(Number(process.env.FAKE_ZCODE_STDERR_BYTES) / (process.env.FAKE_ZCODE_STDERR_TEXT ?? 'sensitive-stderr').length))); const sessions = new Map(); @@ -27,6 +36,8 @@ let pendingConcurrentStopResponse; const pendingCompletionTimers = new Map(); const conversationSubscriptions = new Map(); const conversationSubscriptionCounts = new Map(); +const sessionReadCounts = new Map(); +const sessionProgressRecoveryCompleted = new Set(); const defaultModel = { providerId: 'fake', modelId: 'model' }; function settings(model = defaultModel) { return { appliedProviderRevision: 'provider-revision-1', model: { current: model, available: [{ ref: model, label: 'Fixture model', reasoning: { enabled: true, levels: [{ value: 'low', label: 'Low' }, { value: 'HIGH', label: 'High' }] } }, { ref: { providerId: 'fake2', modelId: 'other' }, label: 'Other model', reasoning: { enabled: true, levels: [{ value: 'XHIGH', label: 'Extreme' }] } }] }, thoughtLevel: { enabled: true, current: 'low', defaultLevel: 'low', available: [{ value: 'low', label: 'Low' }, { value: 'HIGH', label: 'High' }] }, mode: { current: 'build' }, permission: { mode: 'build', rulesRevision: 1 } }; } @@ -75,6 +86,27 @@ function snapshotForMethod(method, sessionId, value = sessions.get(sessionId)) { return result; } +function addSessionProgress(result, mode) { + const assistant = result.messages.findLast((message) => message?.info?.role === 'assistant'); + if (!assistant) return result; + const partBase = { sessionId: assistant.info.sessionId, messageId: assistant.info.messageId }; + const tool = process.env.FAKE_ZCODE_SESSION_PROGRESS_TOOL ?? 'Bash'; + const input = tool === 'Read' + ? { file_path: process.env.FAKE_ZCODE_SESSION_PROGRESS_PATH ?? 'PRIVATE_SNAPSHOT_PATH' } + : { command: 'PRIVATE_SNAPSHOT_COMMAND', capability: 'PRIVATE_SNAPSHOT_CAPABILITY' }; + const state = mode === 'terminal' + ? { status: 'completed', input, output: 'PRIVATE_SNAPSHOT_OUTPUT', title: 'PRIVATE_SNAPSHOT_METADATA', metadata: { secret: 'PRIVATE_SNAPSHOT_METADATA' }, startedAt: 10, completedAt: 20 } + : { status: 'running', input, startedAt: 10, metadata: { secret: 'PRIVATE_SNAPSHOT_METADATA' } }; + assistant.parts.push( + { ...partBase, partId: 'snapshot-private-prose', type: 'text', text: 'PRIVATE_SNAPSHOT_PROSE' }, + { ...partBase, partId: 'snapshot-private-reasoning', type: 'reasoning', text: 'PRIVATE_SNAPSHOT_REASONING' }, + { ...partBase, partId: 'snapshot-private-file', type: 'file', mime: 'text/plain', url: 'PRIVATE_SNAPSHOT_FILE' }, + { ...partBase, partId: 'snapshot-private-patch', type: 'patch', hash: 'PRIVATE_SNAPSHOT_PATCH', files: ['PRIVATE_SNAPSHOT_FILE'] }, + { ...partBase, partId: 'snapshot-tool', type: 'tool', callId: 'PRIVATE_SNAPSHOT_CALL', tool, state, metadata: { secret: 'PRIVATE_SNAPSHOT_METADATA' } }, + ); + return result; +} + async function recoveryMode() { if (!process.env.FAKE_ZCODE_RECOVERY_CONTROL) return null; try { const value = JSON.parse(await readFile(process.env.FAKE_ZCODE_RECOVERY_CONTROL, 'utf8')); return ['active', 'completed', 'stopped', 'missing'].includes(value.mode) ? value.mode : 'active'; } catch { return 'active'; } } function applyRecoveryMode(session, mode) { if (!session || !mode) return; @@ -95,6 +127,16 @@ async function record(message) { function send(message) { process.stdout.write(`${JSON.stringify(message)}\n`); } +async function progressDispatchReleased() { + if (!progressDispatchGate || !progressDispatchGateNonce) return true; + const value = await readFile(progressDispatchGate, 'utf8').then(JSON.parse).catch(() => null); + if (value && typeof value === 'object' && !Array.isArray(value) + && Object.keys(value).sort().join(',') === 'nonce,state,version' + && value.version === 1 && value.nonce === progressDispatchGateNonce && value.state === 'release') return true; + progressDispatchGateChecks += 1; + if (process.env.FAKE_ZCODE_PROGRESS_DISPATCH_GATE_REACHED) await writeFile(process.env.FAKE_ZCODE_PROGRESS_DISPATCH_GATE_REACHED, JSON.stringify({ version: 1, nonce: progressDispatchGateNonce, checks: progressDispatchGateChecks })); + return false; +} async function scheduleCompletion(sessionId, completion) { const reachedDelayMs = Number(process.env.FAKE_ZCODE_COMPLETION_GATE_REACHED_DELAY_MS ?? 0); if (Number.isSafeInteger(reachedDelayMs) && reachedDelayMs > 0) await new Promise((resolve) => setTimeout(resolve, reachedDelayMs)); @@ -102,13 +144,23 @@ async function scheduleCompletion(sessionId, completion) { /** @type {NodeJS.Timeout} */ let timer; const deliver = async () => { if (pendingCompletionTimers.get(sessionId) !== timer) return; + if (process.env.FAKE_ZCODE_WAIT_FOR_PROGRESS_READ === '1' && (sessionReadCounts.get(sessionId) ?? 0) < 1) { + timer = setTimeout(() => { void deliver(); }, 5); pendingCompletionTimers.set(sessionId, timer); return; + } + if (process.env.FAKE_ZCODE_SESSION_PROGRESS_RECOVERY === '1' && !sessionProgressRecoveryCompleted.has(sessionId)) { + timer = setTimeout(() => { void deliver(); }, 5); pendingCompletionTimers.set(sessionId, timer); return; + } + if (!await progressDispatchReleased()) { + timer = setTimeout(() => { void deliver(); }, 5); pendingCompletionTimers.set(sessionId, timer); return; + } if (process.env.FAKE_ZCODE_COMPLETION_GATE) { const state = await readFile(process.env.FAKE_ZCODE_COMPLETION_GATE, 'utf8').catch(() => ''); if (state.trim() !== 'release') { timer = setTimeout(() => { void deliver(); }, 5); pendingCompletionTimers.set(sessionId, timer); return; } } pendingCompletionTimers.delete(sessionId); send(completion); }; - timer = setTimeout(() => { void deliver(); }, 5); pendingCompletionTimers.set(sessionId, timer); + const completionDelayMs = Number(process.env.FAKE_ZCODE_COMPLETION_DELAY_MS ?? 5); + timer = setTimeout(() => { void deliver(); }, Number.isSafeInteger(completionDelayMs) && completionDelayMs >= 0 ? completionDelayMs : 5); pendingCompletionTimers.set(sessionId, timer); } function sendBatch(messages) { process.stdout.write(messages.map((message) => JSON.stringify(message)).join('\n') + '\n'); } function flushConcurrentCreateSubscribe() { @@ -123,8 +175,8 @@ function flushConcurrentStopSubscribe() { sendBatch([pendingConcurrentSubscribeResponse, pendingConcurrentStopResponse]); pendingConcurrentStopResponse = undefined; pendingConcurrentSubscribeResponse = undefined; } -function conversationNotification({ sessionId, subscriptionId, deliveryKind, ordinal, deltas, topic = `conversation/${sessionId}` }) { - return { method: 'v4/conversation/frame', params: { wireVersion: 3, kind: 'complete', deliveryKind, logicalFrameId: `frame-${ordinal}`, logicalFrameOrdinal: ordinal, topic, subscriptionId, frame: { topic, subscriptionId, fromSeq: ordinal, toSeq: ordinal, sentAt: 1_786_233_600_000, payload: { kind: 'deltas', deltas } } } }; +function conversationNotification({ sessionId, subscriptionId, deliveryKind, ordinal, deltas, logicalFrameId = `frame-${ordinal}`, topic = `conversation/${sessionId}` }) { + return { method: 'v4/conversation/frame', params: { wireVersion: 3, kind: 'complete', deliveryKind, logicalFrameId, logicalFrameOrdinal: ordinal, topic, subscriptionId, frame: { topic, subscriptionId, fromSeq: ordinal, toSeq: ordinal, sentAt: 1_786_233_600_000, payload: { kind: 'deltas', deltas } } } }; } function isUnsupportedRuntimePreferencesResponse(message, pending) { @@ -233,8 +285,11 @@ input.on('line', async (line) => { if (process.env.FAKE_ZCODE_BARRIER === '1') send({ method: 'state.updated', params: { type: 'state.updated', scope: 'session', sessionId: p.sessionId, revision: 999, reason: 'prompt_completed', patch: { status: 'idle' } } }); const response = { id: message.id, result: { sessionId: p.sessionId, accepted: true, stateRevision } }; if (process.env.FAKE_ZCODE_BAD_SEND_ONCE === '1' && sendCount === 1) response.result.stateRevision = 'bad'; - if (process.env.FAKE_ZCODE_SYNC_BATCH !== 'stale-valid') send(response); const subscription = conversationSubscriptions.get(p.sessionId); + if (process.env.FAKE_ZCODE_CONVERSATION_SCENARIO === 'zero-online' && subscription) { + send(conversationNotification({ sessionId: p.sessionId, subscriptionId: subscription, deliveryKind: 'online', ordinal: 1, deltas: [] })); + } + if (process.env.FAKE_ZCODE_SYNC_BATCH !== 'stale-valid') send(response); if (process.env.FAKE_ZCODE_CONVERSATION_PROGRESS === '1' && subscription) { const base = { rowId: 41, turnId: 'turn-1', createdAt: 1_786_233_600_000, createdAtSeq: 41, kind: 'toolCall', toolCallId: 'tool-command-1', toolName: 'Bash', input: { command: 'npm\ttest', reasoning: 'reasoning must stay private', brokerToken: 'capability must stay private' }, inputText: '{"command":"raw output"}', startedAt: 1_786_233_600_000 }; send(conversationNotification({ sessionId: p.sessionId, subscriptionId: subscription, deliveryKind: 'online', ordinal: 2, deltas: [{ op: 'row.upserted', row: { ...base, status: 'inputStreaming' } }] })); @@ -282,6 +337,26 @@ input.on('line', async (line) => { if (process.env.FAKE_ZCODE_CONCURRENT_CREATE_SUBSCRIBE_BATCH === '1' || process.env.FAKE_ZCODE_CONCURRENT_CREATE_SUBSCRIBE_REVERSE_BATCH === '1') { pendingConcurrentSubscribeResponse = response; flushConcurrentCreateSubscribe(); } else if (process.env.FAKE_ZCODE_CONCURRENT_STOP_SUBSCRIBE_BATCH === '1') { pendingConcurrentSubscribeResponse = response; flushConcurrentStopSubscribe(); } else send(response); + if (process.env.FAKE_ZCODE_CONVERSATION_SCENARIO === 'initial-only') { + send(conversationNotification({ + sessionId, subscriptionId, deliveryKind: 'initial', ordinal: 1, logicalFrameId: 'PRIVATE_INITIAL_FRAME_ID', + deltas: [{ op: 'row.upserted', row: { rowId: 43, turnId: 'PRIVATE_INITIAL_TURN_ID', createdAt: 1_786_233_600_000, createdAtSeq: 43, kind: 'toolCall', toolCallId: 'PRIVATE_INITIAL_TOOL_ID', toolName: 'Bash', status: 'running', inputText: '{"command":"PRIVATE_INITIAL_COMMAND"}', input: { command: 'PRIVATE_INITIAL_COMMAND', reasoning: 'PRIVATE_INITIAL_REASONING' }, startedAt: 1_786_233_600_000 } }], + })); + } + if (process.env.FAKE_ZCODE_CONVERSATION_SCENARIO === 'rejection-burst') { + for (let index = 0; index < 4; index += 1) { + send(conversationNotification({ + sessionId, subscriptionId, deliveryKind: 'online', ordinal: 1, logicalFrameId: `PRIVATE_REJECTED_FRAME_${index}`, + deltas: [{ op: 'row.upserted', row: { rowId: 44 + index, kind: 'toolCall', privateIdentifier: `PRIVATE_REJECTED_ROW_${index}`, inputText: '{"command":"PRIVATE_REJECTED_COMMAND"}', input: { command: 'PRIVATE_REJECTED_COMMAND', reasoning: 'PRIVATE_REJECTED_REASONING' } } }], + })); + } + } + if (process.env.FAKE_ZCODE_CONVERSATION_SCENARIO === 'sequence-gap') { + send(conversationNotification({ sessionId, subscriptionId, deliveryKind: 'recovery', ordinal: 1, deltas: [] })); + for (const ordinal of [3, 4, 5, 6]) send(conversationNotification({ + sessionId, subscriptionId, deliveryKind: 'online', ordinal, logicalFrameId: `PRIVATE_SEQUENCE_FRAME_${ordinal}`, deltas: [], + })); + } if (process.env.FAKE_ZCODE_CONVERSATION_PROGRESS === '1') send(conversationNotification({ sessionId, subscriptionId, deliveryKind: 'initial', ordinal: 1, deltas: [{ op: 'row.upserted', row: { rowId: 40, turnId: 'turn-1', createdAt: 1_786_233_600_000, createdAtSeq: 40, kind: 'toolCall', toolCallId: 'initial', toolName: 'Bash', status: 'inputStreaming', inputText: '{"command":"INITIAL_SECRET"}', input: { command: 'INITIAL_SECRET' }, startedAt: 1_786_233_600_000 } }] })); break; } @@ -293,7 +368,24 @@ input.on('line', async (line) => { break; case 'session/read': { const session = sessions.get(p.sessionId); applyRecoveryMode(session, await recoveryMode()); - send({ id: message.id, result: snapshotForMethod('session/read', p.sessionId, session) }); + const readCount = (sessionReadCounts.get(p.sessionId) ?? 0) + 1; sessionReadCounts.set(p.sessionId, readCount); + if (readCount === 1 && process.env.FAKE_ZCODE_SESSION_PROGRESS_READ_FAIL === '1') { + send({ id: message.id, error: { code: -32099, message: 'PRIVATE_SNAPSHOT_READ_REJECTION' } }); break; + } + let result = snapshotForMethod('session/read', p.sessionId, session); + if (readCount === 1 && process.env.FAKE_ZCODE_SESSION_PROGRESS_RECOVERY === '1') { + result = structuredClone(result); addSessionProgress(result, 'running'); + const tool = result.messages.flatMap((entry) => entry.parts).find((part) => part.type === 'tool'); + if (tool) tool.state.input.command = 'PRIVATE_LATE_SNAPSHOT'; + const subscriptionId = conversationSubscriptions.get(p.sessionId); + if (subscriptionId) send(conversationNotification({ sessionId: p.sessionId, subscriptionId, deliveryKind: 'online', ordinal: 2, deltas: [] })); + await new Promise((resolve) => setTimeout(resolve, 30)); + } + if (readCount === 1 && ['running', 'terminal'].includes(process.env.FAKE_ZCODE_SESSION_PROGRESS)) { + result = structuredClone(result); addSessionProgress(result, process.env.FAKE_ZCODE_SESSION_PROGRESS); + } + send({ id: message.id, result }); + if (readCount === 1 && process.env.FAKE_ZCODE_SESSION_PROGRESS_RECOVERY === '1') sessionProgressRecoveryCompleted.add(p.sessionId); break; } case 'session/resume': diff --git a/tests/helpers/codex-rescue-qualification.mjs b/tests/helpers/codex-rescue-qualification.mjs index e75f80b4..715cc7b7 100644 --- a/tests/helpers/codex-rescue-qualification.mjs +++ b/tests/helpers/codex-rescue-qualification.mjs @@ -5,6 +5,7 @@ const MAX_EVENTS_PER_ROLLOUT = 8_192; const MAX_TEXT_BYTES = 1024 * 1024; const MAX_ROLLOUT_BYTES = 16 * 1024 * 1024; const MAX_EXEC_AGENT_MESSAGES = 256; +const MAX_CHILD_POLLS = 64; const GENERIC_HIDDEN_SCHEMA_VERSIONS = new Set(['0.147.0']); const EXEC_ENVELOPE_KEYS = new Set(['cmd', 'workdir', 'yield_time_ms', 'max_output_tokens']); @@ -119,7 +120,6 @@ function qualifyCodexRescueEvidenceCore(input, options, deferEncryptedSpawnUnqua assertParentPreflight(parent, spawnIndex, startIndex, options); const childCalls = child.filter((event) => event?.type === 'response_item' && event.payload?.type === 'custom_tool_call'); - const childExecCalls = childCalls.filter((event) => event.payload.name === 'exec'); if (childCalls.length === 0) { const unsupportedCalls = child.filter((event) => event?.type === 'response_item' && event.payload?.type === 'function_call' @@ -127,25 +127,25 @@ function qualifyCodexRescueEvidenceCore(input, options, deferEncryptedSpawnUnqua if (unsupportedCalls.length > 0) mismatch('child-command-shape-mismatch', 'The child command used a tool-call shape not captured for Codex 0.147.'); mismatch('child-command-unavailable', 'The child rollout did not expose structured tool-call evidence.'); } - if (childCalls.length !== 1 || childExecCalls.length !== 1) mismatch('child-command-count', 'The child must execute exactly one tool call and it must be exec.'); - const childEnvelope = parseCapturedExecEnvelope(childExecCalls[0].payload.input); - assertExecEnvelope(childEnvelope, options.expectedCommand, options.expectedWorkspace, 'child-exec-envelope-mismatch'); - const childOutputs = child.filter((event) => event?.type === 'response_item' && event.payload?.type === 'custom_tool_call_output'); if (childOutputs.length === 0) mismatch('child-output-count', 'The child rollout has no structured exec output.'); - if (childOutputs.length !== 1) mismatch('child-output-count', 'The child rollout must have exactly one structured exec output.'); - if (childOutputs[0].payload.call_id !== childExecCalls[0].payload.call_id) mismatch('child-output-link', 'The child exec output does not link to its unique call.'); - if (child.indexOf(childExecCalls[0]) >= child.indexOf(childOutputs[0])) mismatch('child-output-order', 'The linked child output must follow its exec call.'); - assertSemanticProgress(childOutputs[0].payload.output, options.expectedSemanticProgress); - assertTerminalSentinel(childOutputs[0].payload.output, options.expectedPublicOutput); + const execution = validateChildExecution(child, childCalls, childOutputs, options.expectedCommand, options.expectedWorkspace, { + expectedExitCode: 0, allowLegacyWithoutExit: true, + }); + assertSemanticProgress(execution.output, options.expectedSemanticProgress); + assertTerminalSentinel(execution.output, options.expectedPublicOutput); const childFinalIndex = child.findIndex((event) => event?.type === 'event_msg' && event.payload?.type === 'agent_message' && event.payload.phase === 'final_answer'); - if (childFinalIndex <= child.indexOf(childOutputs[0])) mismatch('child-terminal-order', 'The child final message must follow its linked exec output.'); + if (childFinalIndex <= execution.terminalEventIndex) mismatch('child-terminal-order', 'The child final message must follow its terminal companion output.'); assertParentIsolation(parent, options.expectedPreflightCommand, options.forbiddenParentText ?? []); const childReturnIndex = parent.findIndex((event) => event?.type === 'response_item' && event.payload?.type === 'agent_message' && event.payload.author === agentPath && event.payload.recipient === '/root'); const parentFinalIndex = parent.findIndex((event) => event?.type === 'event_msg' && event.payload?.type === 'agent_message' && event.payload.phase === 'final_answer'); if (childReturnIndex < startIndex || parentFinalIndex <= childReturnIndex) mismatch('parent-terminal-order', 'The linked child return and parent final message are out of order.'); + const terminalTimeline = [execution.terminalEvent, child[childFinalIndex], parent[childReturnIndex], parent[parentFinalIndex]].map(eventTimestamp); + if (terminalTimeline.some((value) => value === undefined) || terminalTimeline.some((value, index) => index > 0 && value <= terminalTimeline[index - 1])) { + mismatch('parent-terminal-timeline', 'Trusted timestamps do not prove child exit before child final, parent return, and parent final.'); + } const childFinal = finalRolloutMessage(child, 'child-terminal-unavailable'); const childReturn = childReturnPayload(parent, agentPath); @@ -154,8 +154,15 @@ function qualifyCodexRescueEvidenceCore(input, options, deferEncryptedSpawnUnqua for (const actual of [childFinal, childReturn, parentFinal, execFinal]) { if (actual !== options.expectedPublicOutput) mismatch('public-output-mismatch', 'Child and parent terminal public output must equal the expected sentinel byte-for-byte.'); } + if (options.requireYieldedExecution && (execution.originalHandle === undefined || execution.pollCount < 1 || !Number.isSafeInteger(execution.terminalExitCode))) { + mismatch('child-yielded-execution-required', 'Required native evidence does not contain a running handle, same-handle poll, and terminal exit code.'); + } const evidence = { parentThreadId, childThreadId, agentPath, taskName: spawnArgs.task_name, agentType, route, publicOutput: execFinal, - ...(options.expectedSemanticProgress === undefined ? {} : { semanticProgressChecked: true }) }; + ...(options.expectedSemanticProgress === undefined ? {} : { semanticProgressChecked: true }), + ...(options.requireYieldedExecution ? { yieldedExecution: { + execCommandCount: execution.execCommandCount, pollCount: execution.pollCount, + sameHandleChecked: true, terminalExitCode: execution.terminalExitCode, + } } : {}) }; if (!spawnMessage) mismatch('spawn-message-unavailable', 'The structured spawn metadata does not expose a bounded message field.'); const spawnMessageEncrypted = encrypted(spawnMessage); if (!spawnMessageEncrypted && spawnMessage !== expectedSpawnMessage) mismatch('spawn-message-mismatch', 'The runtime spawn message differs from the fixed Rescue forwarder contract.'); @@ -164,6 +171,107 @@ function qualifyCodexRescueEvidenceCore(input, options, deferEncryptedSpawnUnqua return evidence; } +function validateChildExecution(child, calls, outputs, expectedCommand, expectedWorkspace, options = {}) { + const code = (suffix) => options.codePrefix ? `${options.codePrefix}-${suffix}` : `child-${suffix}`; + const commandCountCode = options.commandCountCode ?? code('command-count'); + if (calls.length === 0 || calls.length > MAX_CHILD_POLLS + 1 || calls.length !== child.filter((event) => event?.type === 'response_item' && event.payload?.type === 'custom_tool_call').length) { + mismatch(commandCountCode, 'The child must use one exec_command and only bounded continuation polls.'); + } + if (calls.some((call) => call.payload.name !== 'exec')) mismatch(code('tool-name'), 'Every captured child host wrapper must use the exact exec tool name.'); + const parsedCalls = calls.map((call) => parseCapturedHostCall(call.payload.input)); + if (parsedCalls.filter((call) => call.kind === 'exec_command').length !== 1) mismatch(commandCountCode, 'The child started more than one companion process.'); + if (outputs.length !== calls.length) mismatch(code('output-count'), 'Every child host call must have exactly one linked structured output.'); + const callIds = validateCallOutputOwnership(calls, outputs, code('call-id')); + let execCount = 0; let handle; let terminalEventIndex = -1; let terminalCount = 0; let terminalExitCode; const normalized = []; const pollHandles = []; + for (let index = 0; index < calls.length; index += 1) { + const call = calls[index]; + const linked = outputs.filter((event) => event.payload.call_id === call.payload.call_id); + if (linked.length !== 1) mismatch(code('output-link'), 'Every child host call must have exactly one linked output.'); + const outputEvent = linked[0]; + const callIndex = child.indexOf(call); const outputIndex = child.indexOf(outputEvent); + if (callIndex >= outputIndex) mismatch(code('output-order'), 'A linked child host output must follow its call.'); + if (index > 0) { + const previousOutput = outputs.find((event) => event.payload.call_id === calls[index - 1].payload.call_id); + if (child.indexOf(previousOutput) >= callIndex) mismatch(code('output-order'), 'Each continuation call must follow the preceding host result.'); + } + if (terminalCount > 0) mismatch(code('poll-after-terminal'), 'The child polled after the companion process exited.'); + const host = parsedCalls[index]; + if (host.kind === 'exec_command') { + execCount += 1; + if (execCount !== 1 || index !== 0) mismatch(commandCountCode, 'The child started more than one companion process.'); + if (options.commandMismatchCode && host.envelope.get('cmd') !== expectedCommand) mismatch(options.commandMismatchCode, 'The child continuation command differs from the selected constant command.'); + assertExecEnvelope(host.envelope, expectedCommand, expectedWorkspace, code('exec-envelope-mismatch')); + } else { + if (execCount !== 1 || handle === undefined) mismatch(code('handle-mismatch'), 'A continuation poll did not follow the original running handle.'); + assertPollEnvelope(host.envelope, handle, code); + pollHandles.push(host.envelope.get('session_id')); + } + if (host.legacy) { + if (calls.length !== 1) mismatch(code('terminal-exit-missing'), 'A multi-call child execution must expose structured host results.'); + if (!Array.isArray(outputEvent.payload.output) || outputEvent.payload.output.length < 1 || outputEvent.payload.output.length > 8 + || outputEvent.payload.output.some((item) => item?.type !== 'input_text' || boundedString(item.text) === undefined)) mismatch(code('terminal-exit-missing'), 'The one-shot captured result is not terminal.'); + normalized.push(...outputEvent.payload.output); terminalCount += 1; terminalEventIndex = outputIndex; + continue; + } + const result = parseCapturedHostResult(outputEvent.payload.output); + normalized.push({ type: 'input_text', text: result.output }); + const hasHandle = Object.hasOwn(result, 'session_id'); const hasExit = Object.hasOwn(result, 'exit_code'); + if (hasHandle === hasExit) mismatch(code('terminal-exit-missing'), 'A captured host result must expose either one running handle or one exit code.'); + if (hasHandle) { + if (!Number.isSafeInteger(result.session_id) || result.session_id <= 0) mismatch(code('handle-invalid'), 'The running execution handle is not a positive safe integer.'); + if (handle === undefined) handle = result.session_id; + else if (result.session_id !== handle) mismatch(code('handle-mismatch'), 'A continuation result changed the original running handle.'); + } else { + if (!Number.isSafeInteger(result.exit_code)) mismatch(code('terminal-exit-invalid'), 'The terminal exit code is not a safe integer.'); + terminalCount += 1; terminalEventIndex = outputIndex; terminalExitCode = result.exit_code; + if (options.expectedExitCode !== undefined && result.exit_code !== options.expectedExitCode) mismatch(options.expectedExitCodeMismatchCode ?? code('terminal-exit-invalid'), 'The terminal exit code differs from the required child-turn contract.'); + } + } + if (execCount !== 1) mismatch(commandCountCode, 'The child must start exactly one companion process.'); + if (terminalCount !== 1 || terminalEventIndex < 0) mismatch(code('terminal-exit-missing'), 'The original companion process has no unique terminal exit code.'); + if (options.expectedExitCode !== undefined && parsedCalls[0].legacy && !options.allowLegacyWithoutExit) mismatch(code('terminal-exit-missing'), 'This child turn requires an observed terminal exit code.'); + return { + callIds, execCommandCount: execCount, execEvent: calls[0], originalHandle: handle, output: normalized, pollCount: pollHandles.length, + pollHandles, terminalEventIndex, terminalEvent: child[terminalEventIndex], terminalExitCode, + }; +} + +function validateCallOutputOwnership(calls, outputs, errorCode) { + const callIds = calls.map((event) => boundedString(event?.payload?.call_id)); + const outputIds = outputs.map((event) => boundedString(event?.payload?.call_id)); + if (callIds.some((id) => !id) || outputIds.some((id) => !id) + || new Set(callIds).size !== callIds.length || new Set(outputIds).size !== outputIds.length + || callIds.length !== outputIds.length || callIds.some((id) => !outputIds.includes(id))) { + mismatch(errorCode, 'Host calls and outputs require nonempty, bounded, globally unique, one-to-one call IDs.'); + } + return callIds; +} + +function assertPollEnvelope(envelope, expectedHandle, code = (suffix) => `child-${suffix}`) { + const keys = [...envelope.keys()]; + if (keys.some((key) => !['chars', 'max_output_tokens', 'session_id', 'yield_time_ms'].includes(key))) mismatch(code('poll-envelope'), 'The continuation poll contains a forbidden field.'); + if (envelope.get('session_id') !== expectedHandle) mismatch(code('handle-mismatch'), 'The continuation poll changed the original running handle.'); + if (envelope.get('chars') !== '') mismatch(code('poll-input'), 'The continuation poll supplied nonempty input.'); + if (envelope.has('yield_time_ms') && (!Number.isInteger(envelope.get('yield_time_ms')) || envelope.get('yield_time_ms') < 250 || envelope.get('yield_time_ms') > 300_000)) mismatch(code('poll-envelope'), 'The continuation yield bound is unsafe.'); + if (envelope.has('max_output_tokens') && (!Number.isInteger(envelope.get('max_output_tokens')) || envelope.get('max_output_tokens') < 1 || envelope.get('max_output_tokens') > 100_000)) mismatch(code('poll-envelope'), 'The continuation output bound is unsafe.'); +} + +function parseCapturedHostResult(output) { + if (!Array.isArray(output) || output.length !== 2 || output[0]?.type !== 'input_text' || output[1]?.type !== 'input_text' + || !boundedString(output[0].text)?.startsWith('Script completed\n')) mismatch('child-result-shape', 'The host result does not match the captured Codex 0.147 output shape.'); + const text = boundedString(output[1].text); let result; + try { result = JSON.parse(text); } catch { mismatch('child-result-shape', 'The captured host result is not exact JSON.'); } + if (!result || typeof result !== 'object' || Array.isArray(result)) mismatch('child-result-shape', 'The captured host result is not an object.'); + const allowed = new Set(['chunk_id', 'exit_code', 'original_token_count', 'output', 'session_id', 'wall_time_seconds']); + if (Object.keys(result).some((key) => !allowed.has(key)) || typeof result.output !== 'string' || boundedString(result.output) === undefined) mismatch('child-result-shape', 'The captured host result differs from the Codex 0.147 contract.'); + if (Object.hasOwn(result, 'chunk_id') && !boundedString(result.chunk_id) + || Object.hasOwn(result, 'original_token_count') && (!Number.isSafeInteger(result.original_token_count) || result.original_token_count < 0) + || Object.hasOwn(result, 'wall_time_seconds') && (!Number.isFinite(result.wall_time_seconds) || result.wall_time_seconds < 0)) { + mismatch('child-result-shape', 'The captured host result fields exceed their safe bounds.'); + } + return result; +} + export function qualifyCodexRescueBackgroundEvidence(input, options) { const jobId = boundedString(options?.expectedJobId); if (!jobId || !/^[a-f0-9]{64}$/u.test(jobId)) mismatch('background-job-id', 'Background qualification requires one exact canonical queued job ID.'); @@ -196,6 +304,7 @@ export function qualifyCodexRescueChoiceEvidence(input, options) { if (parentCandidates.length !== 1) mismatch('choice-parent-count', 'Choice evidence must contain exactly one parent rollout.'); const parent = parentCandidates[0]; const parentMeta = sessionMeta(parent); + validateChoiceParentCallOwnership(parent); const spawns = namedCalls(parent, 'spawn_agent'); if (spawns.length !== 1) mismatch('choice-spawn-count', 'Choice continuation must retain exactly one initial spawn.'); const spawnArgs = parseObject(spawns[0].payload.arguments, 'choice-spawn-arguments'); @@ -232,6 +341,8 @@ export function qualifyCodexRescueChoiceEvidence(input, options) { if (childCandidates.length !== 1) mismatch('choice-child-count', 'Choice evidence must contain exactly one rollout for the retained child ID.'); const child = childCandidates[0]; const meta = sessionMeta(child); + const childSessionMeta = child.filter((event) => event?.type === 'session_meta'); + if (childSessionMeta.length !== 1 || child.indexOf(childSessionMeta[0]) !== 0) mismatch('choice-child-execution-boundary', 'The child rollout must begin with exactly one session_meta record.'); const spawnMeta = meta?.source?.subagent?.thread_spawn; validateParentChildRoute({ parentMeta, parentThreadId: options.expectedParentThreadId, start: start.payload, childMeta: meta, childThreadId, agentPath, codePrefix: 'choice-' }); if (Object.hasOwn(spawnArgs, 'agent_type') ? spawnMeta.agent_role !== options.expectedAgentType : spawnMeta.agent_role !== null) { @@ -281,23 +392,19 @@ export function qualifyCodexRescueChoiceEvidence(input, options) { if (!(timedOutWaitIndexes[0] < parent.indexOf(lists[0]) && parent.indexOf(linked[0]) < nextWaitIndex && nextWaitIndex < firstReturnIndex)) mismatch('choice-child-state-order', 'Timeout recovery state inspection is out of order.'); } - const childCalls = child.filter((event) => event?.type === 'response_item' && event.payload?.type === 'custom_tool_call'); - const childExecs = childCalls.filter((event) => event.payload.name === 'exec'); - if (childCalls.length !== 2 || childExecs.length !== 2) mismatch('choice-command-count', 'The retained child must run exactly the initial and selected continuation commands.'); - assertExecEnvelope(parseCapturedExecEnvelope(childExecs[0].payload.input), options.expectedInitialCommand, options.expectedWorkspace, 'choice-initial-envelope'); - const choiceEnvelope = parseCapturedExecEnvelope(childExecs[1].payload.input); - if (choiceEnvelope.get('cmd') !== options.expectedChoiceCommand) mismatch('choice-command-mismatch', 'The child continuation command differs from the selected constant command.'); - assertExecEnvelope(choiceEnvelope, options.expectedChoiceCommand, options.expectedWorkspace, 'choice-command-envelope'); - if (child.indexOf(childExecs[0]) >= child.indexOf(childExecs[1])) mismatch('choice-command-order', 'The continuation command must follow the initial command in the same child.'); - - const outputs = child.filter((event) => event?.type === 'response_item' && event.payload?.type === 'custom_tool_call_output'); - if (outputs.length !== 2) mismatch('choice-output-count', 'The same-child flow must expose exactly two linked command outputs.'); - for (let index = 0; index < 2; index += 1) { - if (outputs[index].payload.call_id !== childExecs[index].payload.call_id || child.indexOf(childExecs[index]) >= child.indexOf(outputs[index])) { - mismatch('choice-output-link', 'A choice output is not linked after its command.'); - } - } - const needsChoiceText = terminalOutputText(outputs[0].payload.output, 'choice-needs-choice-output'); + const childFinals = child.filter((event) => event?.type === 'event_msg' && event.payload?.type === 'agent_message' && event.payload.phase === 'final_answer'); + if (childFinals.length !== 2) mismatch('choice-child-terminal-sequence', 'The retained child must finalize exactly two turns.'); + const firstFinalIndex = child.indexOf(childFinals[0]); const secondFinalIndex = child.indexOf(childFinals[1]); + const outsideHostEvents = child.filter((event, index) => isChildHostEvent(event) + && !(index > 0 && index < firstFinalIndex || index > firstFinalIndex && index < secondFinalIndex)); + if (outsideHostEvents.length > 0) mismatch('choice-child-execution-boundary', 'Every child host call and output must belong to exactly one logical execution before its final.'); + const initialEvents = child.slice(1, firstFinalIndex); const continuationEvents = child.slice(firstFinalIndex + 1, secondFinalIndex); + const callsIn = (events) => events.filter((event) => event?.type === 'response_item' && event.payload?.type === 'custom_tool_call'); + const outputsIn = (events) => events.filter((event) => event?.type === 'response_item' && event.payload?.type === 'custom_tool_call_output'); + const initialExecution = validateChildExecution(initialEvents, callsIn(initialEvents), outputsIn(initialEvents), options.expectedInitialCommand, options.expectedWorkspace, { codePrefix: 'choice-initial', commandCountCode: 'choice-command-count', expectedExitCode: 3, expectedExitCodeMismatchCode: 'choice-needs-choice-exit' }); + const continuationExecution = validateChildExecution(continuationEvents, callsIn(continuationEvents), outputsIn(continuationEvents), options.expectedChoiceCommand, options.expectedWorkspace, { codePrefix: 'choice-continuation', commandCountCode: 'choice-command-count', commandMismatchCode: 'choice-command-mismatch', expectedExitCode: 0 }); + if (initialExecution.callIds.some((id) => continuationExecution.callIds.includes(id))) mismatch('choice-child-call-id-reused', 'The two logical executions reused a host call ID.'); + const needsChoiceText = terminalOutputText(initialExecution.output, 'choice-needs-choice-output'); let needsChoice; try { needsChoice = JSON.parse(needsChoiceText); } catch { mismatch('choice-needs-choice-output', 'The first child output is not exact needs-choice JSON.'); } assertExactKeys(needsChoice, ['candidate', 'choices', 'type'], 'choice-needs-choice-output'); @@ -305,11 +412,10 @@ export function qualifyCodexRescueChoiceEvidence(input, options) { mismatch('choice-needs-choice-output', 'The first child output is not the fixed needs-choice response.'); } boundedJson(needsChoice.candidate); - assertTerminalSentinel(outputs[1].payload.output, options.expectedPublicOutput); - const childFinals = child.filter((event) => event?.type === 'event_msg' && event.payload?.type === 'agent_message' && event.payload.phase === 'final_answer'); + assertTerminalSentinel(continuationExecution.output, options.expectedPublicOutput); if (childFinals.length !== 2 || childFinals[0].payload.message !== needsChoiceText || childFinals[1].payload.message !== options.expectedPublicOutput - || child.indexOf(outputs[0]) >= child.indexOf(childFinals[0]) || child.indexOf(childFinals[0]) >= child.indexOf(childExecs[1]) - || child.indexOf(outputs[1]) >= child.indexOf(childFinals[1])) mismatch('choice-child-terminal-sequence', 'The same child must finalize each exact stdout after its linked command output.'); + || child.indexOf(initialExecution.terminalEvent) >= firstFinalIndex || firstFinalIndex >= child.indexOf(continuationExecution.execEvent) + || child.indexOf(continuationExecution.terminalEvent) >= secondFinalIndex) mismatch('choice-child-terminal-sequence', 'The same child must finalize each exact stdout after its linked command output.'); const returns = parent.filter((event) => event?.type === 'response_item' && event.payload?.type === 'agent_message' && event.payload.author === agentPath && event.payload.recipient === '/root'); if (returns.length !== 2) mismatch('choice-child-return-count', 'The parent must receive needs-choice and terminal results from the same child.'); @@ -334,18 +440,45 @@ export function qualifyCodexRescueChoiceEvidence(input, options) { mismatch('choice-wait-order', 'Wait evidence does not bracket the two same-child turns.'); } - const timeline = [childExecs[0], outputs[0], childFinals[0], returns[0], parentFinals[0], followups[0], followupOutputs[0], childExecs[1], outputs[1], childFinals[1], returns[1], parentFinals[1]].map(eventTimestamp); + const timeline = [initialExecution.execEvent, initialExecution.terminalEvent, childFinals[0], returns[0], parentFinals[0], followups[0], followupOutputs[0], continuationExecution.execEvent, continuationExecution.terminalEvent, childFinals[1], returns[1], parentFinals[1]].map(eventTimestamp); if (timeline.some((value) => value === undefined) || timeline.some((value, index) => index > 0 && value <= timeline[index - 1])) { mismatch('choice-terminal-timeline', 'The observable timestamps do not prove the complete initial-exec through terminal-parent sequence.'); } assertParentIsolation(parent, options.expectedPreflightCommand, options.forbiddenParentText ?? []); - const evidence = { parentThreadId: options.expectedParentThreadId, childThreadId, agentPath, choice: options.expectedChoice }; + const evidence = { + parentThreadId: options.expectedParentThreadId, childThreadId, agentPath, choice: options.expectedChoice, + ...(options.includeExecutionFacts ? { executions: { + initial: { execCommandCount: initialExecution.execCommandCount }, + continuation: { execCommandCount: continuationExecution.execCommandCount }, + } } : {}), + }; if (spawnMessageEncrypted) unqualified('choice-spawn-encrypted', 'Codex encrypted only the spawn message field, so its exact runtime value cannot be qualified.', evidence); if (followupMessageEncrypted) unqualified('choice-followup-encrypted', 'Codex encrypted only the continuation message field, so its exact runtime value cannot be qualified.', evidence); return evidence; } +function isChildHostEvent(event) { + return event?.type === 'response_item' && ['custom_tool_call', 'custom_tool_call_output'].includes(event.payload?.type); +} + +function validateChoiceParentCallOwnership(parent) { + const calls = parent.filter((event) => event?.type === 'response_item' && ['function_call', 'custom_tool_call'].includes(event.payload?.type)); + const outputs = parent.filter((event) => event?.type === 'response_item' && ['function_call_output', 'custom_tool_call_output'].includes(event.payload?.type)); + const callIds = calls.map((event) => boundedString(event.payload.call_id)); + const outputIds = outputs.map((event) => boundedString(event.payload.call_id)); + if (callIds.some((id) => !id) || outputIds.some((id) => !id) || new Set(callIds).size !== callIds.length || new Set(outputIds).size !== outputIds.length) { + mismatch('choice-parent-call-id', 'Parent calls and outputs require nonempty bounded unique call IDs.'); + } + const outputOwningCalls = calls.filter((event) => !(event.payload.type === 'function_call' && event.payload.name === 'spawn_agent')); + const linkedOutput = (call) => outputs.filter((output) => output.payload.call_id === call.payload.call_id + && output.payload.type === (call.payload.type === 'custom_tool_call' ? 'custom_tool_call_output' : 'function_call_output')); + if (outputOwningCalls.length !== outputs.length || outputOwningCalls.some((call) => linkedOutput(call).length !== 1) + || outputs.some((output) => !outputOwningCalls.some((call) => linkedOutput(call).includes(output)))) { + mismatch('choice-parent-call-id', 'Every parent host output must belong one-to-one to one non-spawn call.'); + } +} + function validateParentChildRoute({ parentMeta, parentThreadId, start, childMeta, childThreadId, agentPath, codePrefix }) { const code = codePrefix ? 'choice-child-link' : 'child-link-mismatch'; if (parentMeta?.session_id !== parentThreadId || parentMeta?.id !== parentThreadId @@ -368,10 +501,21 @@ function namedCalls(events, name) { function eventTimestamp(event) { const value = boundedString(event?.timestamp); - const match = value === undefined ? null : /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d{1,9}))?Z$/u.exec(value); + const match = value === undefined ? null : /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(Z|([+-])(\d{2}):(\d{2}))$/u.exec(value); if (!match) return undefined; - const seconds = Date.parse(`${match[1]}Z`); if (!Number.isFinite(seconds)) return undefined; - return BigInt(seconds) * 1_000_000n + BigInt((match[2] ?? '').padEnd(9, '0')); + const [year, month, day, hour, minute, second] = match.slice(1, 7).map(Number); + const offsetHour = match[10] === undefined ? 0 : Number(match[10]); const offsetMinute = match[11] === undefined ? 0 : Number(match[11]); + const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); + const daysInMonth = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + if (month < 1 || month > 12 || day < 1 || day > daysInMonth[month - 1] + || hour > 23 || minute > 59 || second > 59 || offsetHour > 23 || offsetMinute > 59) return undefined; + const adjustedYear = year - (month <= 2 ? 1 : 0); const era = Math.floor(adjustedYear / 400); const yearOfEra = adjustedYear - era * 400; + const adjustedMonth = month + (month > 2 ? -3 : 9); const dayOfYear = Math.floor((153 * adjustedMonth + 2) / 5) + day - 1; + const dayOfEra = yearOfEra * 365 + Math.floor(yearOfEra / 4) - Math.floor(yearOfEra / 100) + dayOfYear; + const epochDays = era * 146097 + dayOfEra - 719468; + const offsetSeconds = (offsetHour * 60 + offsetMinute) * 60 * (match[9] === '-' ? -1 : 1); + const epochSeconds = BigInt(epochDays * 86_400 + hour * 3_600 + minute * 60 + second - offsetSeconds); + return epochSeconds * 1_000_000_000n + BigInt((match[7] ?? '').padEnd(9, '0')); } function childReturnText(event, agentPath) { @@ -455,15 +599,20 @@ function assertTerminalSentinel(output, sentinel) { function assertSemanticProgress(output, expected) { if (expected === undefined) return; - assertExactKeys(expected, ['start', 'terminal'], 'semantic-progress-contract'); + assertExactKeys(expected, ['start', 'terminal', 'snapshotFallback', 'lifecycleOnly'], 'semantic-progress-contract'); const start = boundedString(expected.start); const terminal = boundedString(expected.terminal); - if (!start || !terminal || !start.startsWith('[zcode] ') || !terminal.startsWith('[zcode] ')) mismatch('semantic-progress-contract', 'Semantic progress expectations must be exact bounded allowlisted ZCode lines.'); + const snapshotFallback = boundedString(expected.snapshotFallback); const lifecycleOnly = boundedString(expected.lifecycleOnly); + if (!start || !terminal || !snapshotFallback || !lifecycleOnly + || ![start, terminal, snapshotFallback, lifecycleOnly].every((line) => line.startsWith('[zcode] '))) mismatch('semantic-progress-contract', 'Semantic progress expectations must be exact bounded allowlisted ZCode lines.'); terminalOutputText(output, 'semantic-progress-missing'); const lines = output.flatMap((item) => item.text.split('\n')); const startIndexes = lines.map((line, index) => line === start ? index : -1).filter((index) => index >= 0); const terminalIndexes = lines.map((line, index) => line === terminal ? index : -1).filter((index) => index >= 0); - if (startIndexes.length !== 1 || terminalIndexes.length !== 1 || startIndexes[0] >= terminalIndexes[0]) { - mismatch('semantic-progress-missing', 'The child transcript lacks one ordered exact semantic progress start and terminal line.'); + const diagnosticCounts = [snapshotFallback, lifecycleOnly].map((diagnostic) => lines.filter((line) => line === diagnostic).length); + const hasSemanticPair = startIndexes.length === 1 && terminalIndexes.length === 1 && startIndexes[0] < terminalIndexes[0]; + const hasExactCompatibilityDiagnostic = diagnosticCounts.some((count) => count === 1) && diagnosticCounts.every((count) => count <= 1); + if (!hasSemanticPair && !hasExactCompatibilityDiagnostic) { + mismatch('semantic-progress-missing', 'The child transcript lacks one ordered safe semantic progress pair or one exact degraded diagnostic.'); } } @@ -578,6 +727,23 @@ function parseCapturedExecEnvelope(input) { return values; } +function parseCapturedHostCall(input) { + const source = boundedString(input); + if (!source) mismatch('child-command-encoding', 'The child host-call evidence is absent.'); + const structured = [ + ['exec_command', 'const r = await tools.exec_command('], + ['write_stdin', 'const r = await tools.write_stdin('], + ]; + const suffix = '); text(JSON.stringify(r))\n'; + for (const [kind, prefix] of structured) { + if (!source.startsWith(prefix)) continue; + if (!source.endsWith(suffix)) continue; + if (source.indexOf(`tools.${kind}`, prefix.length) !== -1) mismatch('child-command-encoding', 'The child host call does not match the captured Codex 0.147 wrapper.'); + return { kind, envelope: parseTopLevelExecObject(source.slice(prefix.length, -suffix.length)), legacy: false }; + } + return { kind: 'exec_command', envelope: parseCapturedExecEnvelope(input), legacy: true }; +} + function parseTopLevelExecObject(source) { let offset = skipWhitespace(source, 0); if (source[offset] !== '{') mismatch('child-command-encoding', 'The captured exec argument must be one object literal.'); diff --git a/tests/hooks.test.mjs b/tests/hooks.test.mjs index 43f1061e..ac3642a6 100644 --- a/tests/hooks.test.mjs +++ b/tests/hooks.test.mjs @@ -139,6 +139,44 @@ test('two sessions in one workspace get isolated caller capabilities, permission assert.doesNotMatch(allStored, /ZCODE_CALLER_CONTEXT/); }); +test('forwarding-child prompt hooks are accepted neutrally and malformed child identities fail closed', async () => { + const { cwd, data, env } = await workspace(); + const identity = createIdentityStore({ dataRoot: data }); + const store = createStateStore({ dataRoot: data }); + await runHook('session-lifecycle-hook.mjs', { session_id: 'parent', cwd, hook_event_name: 'SessionStart', transcript_path: null, model: 'gpt', permission_mode: 'default', source: 'startup' }, env); + await runHook('user-prompt-hook.mjs', { session_id: 'parent', turn_id: 'parent-turn', cwd, hook_event_name: 'UserPromptSubmit', transcript_path: null, model: 'gpt', permission_mode: 'plan', prompt: 'parent prompt' }, env); + const job = await store.reserveJob({ workspace: cwd, ownerSessionId: 'parent', ownerTurnId: 'parent-turn', command: 'rescue', readOnly: true, permissionSnapshot: { permissionMode: 'plan' } }); + await store.finishJob(cwd, job.id, ['queued'], 'failed', { error: { code: 'TEST_FAILURE', message: 'seed unread completion' }, exitCode: 1 }); + const beforeTurn = await identity.resolveActiveTurn({ sessionId: 'parent', workspace: cwd }); + const snapshot = async () => Object.fromEntries(await Promise.all((await jsonFiles(data)).sort().map(async (path) => [path, await readFile(path, 'utf8')]))); + const beforeState = await snapshot(); + const childPrompt = { + session_id: 'parent', turn_id: 'child-turn', cwd, + hook_event_name: 'UserPromptSubmit', transcript_path: null, + model: 'gpt', permission_mode: 'bypassPermissions', prompt: 'forward', + agent_id: 'rescue-child', agent_type: 'zcode-rescue', + }; + + const accepted = await runHook('user-prompt-hook.mjs', childPrompt, env); + assert.equal(accepted.code, 0); assert.equal(accepted.stdout, '{}'); assert.deepEqual(accepted.json, {}); + assert.deepEqual(await identity.resolveActiveTurn({ sessionId: 'parent', workspace: cwd }), beforeTurn, 'forwarded prompt must not replace the parent caller turn or permission/prompt snapshot'); + assert.deepEqual(await snapshot(), beforeState, 'forwarded prompt must not create a child caller, gate baseline, or unread-job marker'); + + const invalidInputs = [ + { name: 'agent_id only', input: { ...childPrompt, agent_type: undefined } }, + { name: 'agent_type only', input: { ...childPrompt, agent_id: undefined } }, + { name: 'empty identity', input: { ...childPrompt, agent_id: '' } }, + { name: 'control-bearing identity', input: { ...childPrompt, agent_id: 'rescue\0child' } }, + { name: 'oversized identity', input: { ...childPrompt, agent_id: 'x'.repeat(513) } }, + { name: 'unknown field', input: { ...childPrompt, extra: true } }, + ]; + for (const { name, input } of invalidInputs) { + const result = await runHook('user-prompt-hook.mjs', input, env); + assert.notEqual(result.code, 0, name); assert.equal(result.stdout, '', name); + assert.deepEqual(await snapshot(), beforeState, `${name} must fail before durable state changes`); + } +}); + test('caller authorization survives non-Git workspaces while gate baseline stays unavailable', async () => { const cwd = await mkdtemp(join(tmpdir(), 'zpc-nongit-')); const data = await mkdtemp(join(tmpdir(), 'zpc-hooks-data-')); const env = { PLUGIN_DATA: data }; await runHook('session-lifecycle-hook.mjs', { session_id: 'nongit', cwd, hook_event_name: 'SessionStart', transcript_path: null, model: 'gpt', permission_mode: 'default', source: 'startup' }, env); diff --git a/tests/integration/companion.test.mjs b/tests/integration/companion.test.mjs index 8e533c8a..eadf74f5 100644 --- a/tests/integration/companion.test.mjs +++ b/tests/integration/companion.test.mjs @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import { EventEmitter } from 'node:events'; import { spawn } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; import { mkdir, mkdtemp, readFile, readdir, realpath, rename, rm, stat, symlink, unlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { basename, join } from 'node:path'; @@ -143,6 +144,54 @@ async function companion(context, args, extraEnv = {}, authorization = { callerC return { ...result, json: result.internal ? JSON.parse(result.internal) : null }; } +/** @param {any} context @param {'initial-only'|'zero-online'|'rejection-burst'|'sequence-gap'} scenario @param {{heartbeat?:boolean,env?:NodeJS.ProcessEnv,completionAfterProgressLine?:string}} [options] */ +async function deterministicConversationScenario(context, scenario, options = {}) { + const record = join(context.directory, `${scenario}-conversation-requests.jsonl`); + const owner = caller(`conversation-${scenario}`); const lines = /** @type {string[]} */ ([]); + const gateNonce = options.completionAfterProgressLine ? randomBytes(32).toString('hex') : undefined; + const gatePath = gateNonce ? join(context.directory, `${scenario}-${gateNonce}-progress-dispatch-gate.json`) : undefined; + let gateTimedOut = false; let gateWriteError; let observedExpectedLine = false; let gateDeadline; + const releaseGate = async () => { + if (!gatePath || !gateNonce) return; + try { await writeFile(gatePath, JSON.stringify({ version: 1, nonce: gateNonce, state: 'release' }), { mode: 0o600 }); } + catch (error) { gateWriteError ??= error; } + }; + if (gatePath && gateNonce) await writeFile(gatePath, JSON.stringify({ version: 1, nonce: gateNonce, state: 'held' }), { mode: 0o600 }); + let output; + try { + if (gatePath) { + gateDeadline = setTimeout(() => { gateTimedOut = true; void releaseGate(); }, 5_000); + gateDeadline.unref?.(); + } + output = await runCompanion(['rescue', '--fresh', `${scenario} conversation compatibility`], { + cwd: context.workspace, + env: { + ...context.env, ...options.env, FAKE_ZCODE_CONVERSATION_SCENARIO: scenario, FAKE_ZCODE_RECORD: record, + ...(gatePath && gateNonce ? { FAKE_ZCODE_PROGRESS_DISPATCH_GATE: gatePath, FAKE_ZCODE_PROGRESS_DISPATCH_GATE_NONCE: gateNonce } : {}), + }, + caller: owner, + progressWriter: (line) => { + lines.push(line); + if (line === options.completionAfterProgressLine) { observedExpectedLine = true; void releaseGate(); } + }, + ...(options.heartbeat ? { progressDependencies: { + now: () => new Date().toISOString(), + setInterval: (/** @type {()=>void} */ callback) => { queueMicrotask(callback); return { unref() {} }; }, + clearInterval: () => {}, + } } : {}), + }); + } finally { + if (gateDeadline) clearTimeout(gateDeadline); + await releaseGate(); + } + if (gateTimedOut || !observedExpectedLine && options.completionAfterProgressLine) throw new Error(`expected public progress line was not dispatched: ${options.completionAfterProgressLine}`); + if (gateWriteError) throw gateWriteError; + const status = await runCompanion(['status', output.job.id], { cwd: context.workspace, env: context.env, caller: owner }); + const stored = await createStateStore({ dataRoot: context.dataRoot }).readJob(context.workspace, output.job.id); + const requests = (await readFile(record, 'utf8')).trim().split('\n').filter(Boolean).map((line) => JSON.parse(line)); + return { lines, output, requests, status, stored }; +} + /** @param {()=>Promise} predicate @param {string} message */ async function waitFor(predicate, message) { const deadline = Date.now() + 5_000; @@ -356,8 +405,8 @@ test('foreground rescue streams safe progress to stderr and durably exposes it t }); test('conversation online progress reaches stderr and preview while initial and foreign frames stay private', async () => { - const context = await fixture(); - const result = await companion(context, ['rescue', '--fresh', 'surface conversation progress'], { FAKE_ZCODE_CONVERSATION_PROGRESS: '1' }); + const context = await fixture(); const record = join(context.directory, 'conversation-progress-requests.jsonl'); + const result = await companion(context, ['rescue', '--fresh', 'surface conversation progress'], { FAKE_ZCODE_CONVERSATION_PROGRESS: '1', FAKE_ZCODE_RECORD: record }); assert.equal(result.code, 0, `${result.stderr}${result.stdout}`); assert.equal(result.json.result, 'done'); assert.match(result.stderr, /\[zcode\] Running command: npm test\./); assert.match(result.stderr, /\[zcode\] Command completed: npm test \(25ms\)\./); @@ -365,6 +414,113 @@ test('conversation online progress reaches stderr and preview while initial and const status = await companion(context, ['status', result.json.job.id]); assert.match(JSON.stringify(status.json.job.progressPreview), /Running command: npm test/); assert.doesNotMatch(JSON.stringify(status.json.job.progressPreview), /INITIAL_SECRET|FOREIGN_SECRET/); + assert.equal(status.json.job.progressProbe.state, 'online'); + assert.equal(status.json.job.progressProbe.acceptedOnline, 2); + const requests = (await readFile(record, 'utf8')).trim().split('\n').map((line) => JSON.parse(line)); + assert.equal(requests.filter((request) => request.method === 'session/read').length, 1, 'Task 3 progress must not add snapshot reads'); +}); + +test('initial-only conversation frames deterministically degrade on heartbeat without leaking frame material', async () => { + const context = await fixture(); const scenario = await deterministicConversationScenario(context, 'initial-only', { heartbeat: true }); + const diagnostic = '[zcode] ZCode conversation frames were unavailable; using bounded session progress.\n'; + assert.equal(scenario.lines.filter((line) => line === diagnostic).length, 1); + assert.equal(scenario.output.result, 'done'); assert.equal(scenario.output.job.status, 'succeeded'); assert.equal(scenario.output.job.exitCode, 0); + assert.equal(scenario.stored.progressProbe.state, 'snapshot-fallback'); + assert.equal(scenario.stored.progressProbe.acceptedInitial, 1); assert.equal(scenario.stored.progressProbe.acceptedOnline, 0); + assert.deepEqual(scenario.status.job.progressProbe, scenario.stored.progressProbe); + const visible = `${scenario.lines.join('')}${renderOutput(scenario.output, { json: true })}${JSON.stringify(scenario.status)}`; + assert.doesNotMatch(visible, /PRIVATE_INITIAL_(?:FRAME_ID|TURN_ID|TOOL_ID|COMMAND|REASONING)/); + assert.equal(scenario.requests.filter((request) => request.method === 'session/read').length, 2, 'one progress read remains separate from the final authoritative read'); +}); + +test('initial-only frames fall back to safe current-turn session tool progress without changing the final result', async () => { + const context = await fixture(); + const pathSentinel = 'CONTAINED_SNAPSHOT_PATH_SENTINEL.txt'; + const scenario = await deterministicConversationScenario(context, 'initial-only', { + heartbeat: true, completionAfterProgressLine: '[zcode] Running tool: Read.\n', env: { + FAKE_ZCODE_SESSION_PROGRESS: 'running', + FAKE_ZCODE_SESSION_PROGRESS_TOOL: 'Read', FAKE_ZCODE_SESSION_PROGRESS_PATH: join(context.workspace, pathSentinel), + }, + }); + const visible = `${scenario.lines.join('')}${renderOutput(scenario.output, { json: true })}${JSON.stringify(scenario.status)}`; + assert.match(visible, /ZCode conversation frames were unavailable; using bounded session progress\./); + assert.match(visible, /Running tool: Read\./); + assert.doesNotMatch(scenario.lines.join(''), new RegExp(pathSentinel)); + assert.doesNotMatch(JSON.stringify(scenario.status.job.progressPreview), new RegExp(pathSentinel)); + assert.doesNotMatch(visible, /PRIVATE_SNAPSHOT_(?:PROSE|REASONING|COMMAND|OUTPUT|ERROR|METADATA|FILE|PATCH|CALL|CAPABILITY)/); + assert.equal(scenario.output.result, 'done'); assert.equal(scenario.output.job.status, 'succeeded'); assert.equal(scenario.output.job.exitCode, 0); + assert.equal(scenario.requests.filter((request) => request.method === 'session/read').length, 2); +}); + +test('snapshot fallback emits a terminal-only safe event when the call first appears terminal', async () => { + const context = await fixture(); + const scenario = await deterministicConversationScenario(context, 'initial-only', { + heartbeat: true, completionAfterProgressLine: '[zcode] Bash completed (10ms).\n', + env: { FAKE_ZCODE_SESSION_PROGRESS: 'terminal' }, + }); + const visible = `${scenario.lines.join('')}${renderOutput(scenario.output, { json: true })}${JSON.stringify(scenario.status)}`; + assert.match(visible, /Bash completed \(10ms\)\./); assert.doesNotMatch(visible, /Running tool: Bash\./); + assert.doesNotMatch(visible, /PRIVATE_SNAPSHOT_/); + assert.equal(scenario.output.result, 'done'); assert.equal(scenario.requests.filter((request) => request.method === 'session/read').length, 2); +}); + +test('snapshot read rejection degrades once to lifecycle-only and preserves authoritative completion', async () => { + const context = await fixture(); + const scenario = await deterministicConversationScenario(context, 'initial-only', { + heartbeat: true, completionAfterProgressLine: '[zcode] ZCode semantic progress is unavailable; lifecycle updates will continue.\n', + env: { FAKE_ZCODE_SESSION_PROGRESS_READ_FAIL: '1' }, + }); + const fallback = '[zcode] ZCode conversation frames were unavailable; using bounded session progress.\n'; + const degraded = '[zcode] ZCode semantic progress is unavailable; lifecycle updates will continue.\n'; + assert.equal(scenario.lines.filter((line) => line === fallback).length, 1); + assert.equal(scenario.lines.filter((line) => line === degraded).length, 1); + assert.equal(scenario.stored.progressProbe.state, 'lifecycle-only'); + assert.equal(scenario.output.result, 'done'); assert.equal(scenario.output.job.status, 'succeeded'); assert.equal(scenario.output.job.exitCode, 0); + assert.doesNotMatch(scenario.lines.join(''), /PRIVATE_SNAPSHOT_READ_REJECTION/); + assert.equal(scenario.requests.filter((request) => request.method === 'session/read').length, 2); +}); + +test('later accepted online recovery stops snapshot reads and discards a delayed old result', async () => { + const context = await fixture(); + const scenario = await deterministicConversationScenario(context, 'initial-only', { + heartbeat: true, env: { FAKE_ZCODE_SESSION_PROGRESS_RECOVERY: '1', FAKE_ZCODE_WAIT_FOR_PROGRESS_READ: '1' }, + }); + const visible = `${scenario.lines.join('')}${renderOutput(scenario.output, { json: true })}${JSON.stringify(scenario.status)}`; + assert.equal(scenario.stored.progressProbe.state, 'online'); assert.equal(scenario.stored.progressProbe.acceptedOnline, 1); + assert.doesNotMatch(visible, /PRIVATE_LATE_SNAPSHOT|Running tool: Bash\./); + assert.equal(scenario.output.result, 'done'); assert.equal(scenario.output.job.status, 'succeeded'); assert.equal(scenario.output.job.exitCode, 0); + assert.equal(scenario.requests.filter((request) => request.method === 'session/read').length, 2, 'one late progress read plus one final authoritative read'); +}); + +test('accepted zero-event online conversation frame prevents deterministic heartbeat fallback', async () => { + const context = await fixture(); const scenario = await deterministicConversationScenario(context, 'zero-online', { heartbeat: true }); + assert.equal(scenario.output.result, 'done'); assert.equal(scenario.output.job.status, 'succeeded'); assert.equal(scenario.output.job.exitCode, 0); + assert.equal(scenario.stored.progressProbe.state, 'online'); assert.equal(scenario.stored.progressProbe.acceptedOnline, 1); + assert.doesNotMatch(scenario.lines.join(''), /ZCode (?:conversation frames were unavailable|semantic progress is unavailable)/); + assert.deepEqual(scenario.status.job.progressProbe, scenario.stored.progressProbe); + assert.equal(scenario.requests.filter((request) => request.method === 'session/read').length, 1, 'Task 3 progress must not add snapshot reads'); +}); + +test('malformed conversation rejection burst degrades once without leaking rejected payloads or changing completion', async () => { + const context = await fixture(); const scenario = await deterministicConversationScenario(context, 'rejection-burst'); + const diagnostic = '[zcode] ZCode conversation frames were unavailable; using bounded session progress.\n'; + assert.equal(scenario.lines.filter((line) => line === diagnostic).length, 1); + assert.equal(scenario.output.result, 'done'); assert.equal(scenario.output.job.status, 'succeeded'); assert.equal(scenario.output.job.exitCode, 0); + assert.equal(scenario.stored.progressProbe.state, 'snapshot-fallback'); assert.equal(scenario.stored.progressProbe.rejected['row-shape'], 4); + assert.deepEqual(scenario.status.job.progressProbe, scenario.stored.progressProbe); + const visible = `${scenario.lines.join('')}${renderOutput(scenario.output, { json: true })}${JSON.stringify(scenario.status)}`; + assert.doesNotMatch(visible, /PRIVATE_REJECTED_(?:FRAME|ROW|COMMAND|REASONING)/); + assert.equal(scenario.requests.filter((request) => request.method === 'session/read').length, 2, 'one progress read remains separate from the final authoritative read'); +}); + +test('repeated sequence gaps reach the owner-visible rejection threshold and activate fallback', async () => { + const context = await fixture(); const scenario = await deterministicConversationScenario(context, 'sequence-gap'); + assert.equal(scenario.output.result, 'done'); assert.equal(scenario.stored.progressProbe.rejected.sequence, 4); + assert.equal(scenario.stored.progressProbe.state, 'snapshot-fallback'); + assert.deepEqual(scenario.status.job.progressProbe, scenario.stored.progressProbe); + assert.equal(scenario.lines.filter((line) => /conversation frames were unavailable/.test(line)).length, 1); + const visible = `${scenario.lines.join('')}${JSON.stringify(scenario.status)}`; + assert.doesNotMatch(visible, /PRIVATE_SEQUENCE_FRAME|logicalFrame|ordinal|fromSeq|toSeq/); }); test('conversation online progress sent before the subscribe response is buffered until the subscription binds', async () => { @@ -846,25 +1002,40 @@ test('status --all reports a scavenged foreign job only through redacted other-o test('status --all preserves same-owner detail but allowlists foreign job metadata', async () => { const context = await fixture(); const store = createStateStore({ dataRoot: context.dataRoot }); const mine = await store.reserveJob({ workspace: context.workspace, ownerSessionId: 'owner-a', ownerTurnId: 'owner-a-turn', command: 'review', readOnly: true, permissionSnapshot: { permissionMode: 'read-only' } }); - const foreignQueued = await store.reserveJob({ workspace: context.workspace, ownerSessionId: 'owner-b-secret-session', ownerTurnId: 'owner-b-secret-turn', command: 'rescue', readOnly: true, permissionSnapshot: { permissionMode: 'bypassPermissions', secret: 'permission-secret' } }); const startedAt = new Date().toISOString(); + await store.transitionJob(context.workspace, mine.id, ['queued'], 'running', { startedAt }); + const probe = { + state: 'online', subscriptionAcknowledged: true, framesReceived: 1, + acceptedInitial: 0, acceptedOnline: 1, acceptedRecovery: 0, + rejected: { 'wire-version': 0, 'envelope-shape': 0, sequence: 0, topic: 0, 'row-kind': 0, 'row-shape': 0 }, + snapshotFallbackActive: false, snapshotFallbackUnavailable: false, + }; + await store.updateJobProgressProbe(context.workspace, mine.id, probe); + await store.finishJob(context.workspace, mine.id, ['running'], 'failed', { error: { message: 'fixture terminal' }, exitCode: 1 }); + const foreignQueued = await store.reserveJob({ workspace: context.workspace, ownerSessionId: 'owner-b-secret-session', ownerTurnId: 'owner-b-secret-turn', command: 'rescue', readOnly: true, permissionSnapshot: { permissionMode: 'bypassPermissions', secret: 'permission-secret' } }); + const foreignStartedAt = new Date().toISOString(); const foreign = await store.transitionJob(context.workspace, foreignQueued.id, ['queued'], 'running', { childPid: 424242, workerLeaseId: 'a'.repeat(64), effort: 'xhigh', model: { providerId: 'secret-provider', modelId: 'secret-model' }, - promptArtifact: 'artifacts/secret-prompt.json', startedAt, zcodeSessionId: 'secret-zcode-session', + promptArtifact: 'artifacts/secret-prompt.json', startedAt: foreignStartedAt, zcodeSessionId: 'secret-zcode-session', }); await store.transitionJob(context.workspace, foreign.id, ['running'], 'running', { inputId: 'secret-input', startRevision: 42, beforeMessageIds: ['secret-message'] }); - await store.updateJobProgress(context.workspace, foreign.id, { phase: 'running', message: 'foreign preview secret', observedAt: startedAt }); + await store.updateJobProgress(context.workspace, foreign.id, { phase: 'running', message: 'foreign preview secret', observedAt: foreignStartedAt }); const listed = await runCompanion(['status', '--all'], { cwd: context.workspace, env: context.env, caller: caller('owner-a') }); const sameOwner = listed.jobs.find((/** @type {any} */ job) => job.id === mine.id); const otherOwner = listed.jobs.find((/** @type {any} */ job) => job.id === foreign.id); assert.equal(sameOwner.command, 'review'); assert.equal(sameOwner.readOnly, true); assert.equal(sameOwner.owner, 'same-owner'); + assert.equal(Object.hasOwn(sameOwner, 'progressProbe'), false); assert.deepEqual(Object.keys(otherOwner).sort(), ['createdAt', 'hasOwner', 'id', 'lastActivityAt', 'startedAt', 'status'].sort()); assert.equal(otherOwner.hasOwner, true); const rendered = renderOutput(listed); assert.doesNotMatch(`${JSON.stringify(otherOwner)}\n${rendered}`, /owner-b|secret|xhigh|424242|latest=|result|internal/i); + const detailed = await runCompanion(['status', mine.id], { cwd: context.workspace, env: context.env, caller: caller('owner-a') }); + assert.deepEqual(detailed.job.progressProbe, probe); + assert.doesNotMatch(renderOutput(detailed), /progressProbe|framesReceived|acceptedOnline/); + await assert.rejects(runCompanion(['status', foreign.id], { cwd: context.workspace, env: context.env, caller: caller('owner-a') }), { code: 'OWNED_JOB_NOT_FOUND' }); }); diff --git a/tests/integration/skills.test.mjs b/tests/integration/skills.test.mjs index f6f395a8..61c4b56e 100644 --- a/tests/integration/skills.test.mjs +++ b/tests/integration/skills.test.mjs @@ -348,6 +348,25 @@ test('installed Rescue instructions keep needs-choice and every wait continuatio assert.match(role, /For the exact fresh continuation above, run only:[\s\S]+invoke-choice rescue fresh/); }); +test('installed named and generic Rescue forwarders define terminal yielded-execution handling identically', async () => { + const source = await readFile(join(root, 'skills', 'rescue', 'SKILL.md'), 'utf8'); + const role = await readFile(join(root, 'agents', 'zcode-rescue.toml.template'), 'utf8'); + const generic = /```text\n(Act only as the installed ZCode Rescue forwarder\.[\s\S]+?)\n```/.exec(source)?.[1]; + assert.ok(generic); + const semantics = [ + /result containing an exit code is terminal/i, + /running execution or session handle is nonterminal/i, + /poll only that same handle with the host continuation tool until it reports an exit code/i, + /Partial stdout, stderr, heartbeat text, or an outer code-cell completion is not terminal/i, + /needs-choice response with exit code 3 is terminal for the current child turn/i, + /exactly one `exec_command` companion process/i, + /continuation calls only observe its original running handle/i, + ]; + for (const forwarder of [role, generic]) for (const contract of semantics) assert.match(forwarder, contract); + assert.equal((role.match(/invoke rescue/g) ?? []).length, 1); + assert.equal((generic.match(/invoke rescue/g) ?? []).length, 1); +}); + test('invoke-choice executes with the originating permission snapshot in both directions', async (t) => { const ctx = await fixture(t); const identity = createIdentityStore({ dataRoot: ctx.env.PLUGIN_DATA }); const record = join(ctx.workspace, 'permission-record.jsonl'); const env = { ...ctx.env, FAKE_ZCODE_PERMISSION: '1', FAKE_ZCODE_PERMISSION_RISK: 'high', FAKE_ZCODE_RECORD: record }; diff --git a/tests/job-control.test.mjs b/tests/job-control.test.mjs index 6d32873e..44b36c8b 100644 --- a/tests/job-control.test.mjs +++ b/tests/job-control.test.mjs @@ -587,6 +587,69 @@ test('executor persists the accepted turn boundary and worker identity before st const persisted = await store.readJob(workspace, job.id); assert.equal(persisted.status, 'failed'); assert.equal(persisted.inputId, 'input-boundary'); assert.equal(persisted.childPid, 4321); assert.equal(persisted.workerLeaseId, workerLeaseId); }); +test('executor activates bounded snapshot progress only after the exact accepted boundary is durable and keeps final read authoritative', async () => { + const { root, workspace, store } = await setup(); const job = await store.reserveJob({ workspace, ...reservation }); + /** @type {string[]} */ + const lines = []; + /** @type {string[]} */ + const readKinds = []; + let heartbeatAssigned = false; + /** @type {()=>void} */ + let heartbeat = () => { throw new Error('heartbeat was not assigned'); }; + let releaseCompletion = () => {}; + const completion = new Promise((resolve) => { releaseCompletion = () => resolve(undefined); }); + let boundaryPersisted = false; let signalBoundary = () => {}; + const boundaryReady = new Promise((resolve) => { signalBoundary = () => resolve(undefined); }); + const client = { + createSession: async () => ({ + session: { sessionId: 'zs-snapshot-boundary' }, + settings: { model: { current: { providerId: 'p', modelId: 'm' }, available: [] } }, + messages: [{ info: { messageId: 'historical' } }], + }), + setPermissionHandler: () => {}, subscribe: silentSubscribe, + send: async () => ({ inputId: 'accepted-input', stateRevision: 7 }), + waitForCompletion: async () => completion, + readSession: async () => { + assert.equal(boundaryPersisted, true, 'no session read may precede durable boundary persistence'); + if (readKinds.length === 0) { + readKinds.push('progress'); + return { + runtime: { stateRevision: 8 }, + messages: [ + { info: { role: 'user', messageId: 'accepted-input' }, parts: [] }, + { info: { role: 'assistant', messageId: 'assistant-progress', parentMessageId: 'accepted-input' }, parts: [ + { type: 'tool', callId: 'safe-call', tool: 'Bash', state: { status: 'running', input: { command: 'PRIVATE_COMMAND' }, startedAt: 1 } }, + ] }, + ], + }; + } + readKinds.push('final'); + return { messages: [{ info: { role: 'assistant', messageId: 'assistant-final', parentMessageId: 'accepted-input' }, parts: [{ type: 'text', text: 'authoritative result' }] }] }; + }, + close: async () => {}, + }; + const execution = executeJob({ + job, workspace, dataRoot: join(root, 'data'), store, client, task: 'task', progressWriter: (line) => lines.push(line), + onBoundaryPersisted: async (running) => { + assert.equal(running.inputId, 'accepted-input'); assert.equal(running.startRevision, 7); assert.deepEqual(running.beforeMessageIds, ['historical']); + boundaryPersisted = true; signalBoundary(); + }, + progressDependencies: { + now: () => '2026-08-15T00:00:00.000Z', + setInterval: (callback) => { heartbeat = callback; heartbeatAssigned = true; return { unref() {} }; }, clearInterval: () => {}, + }, + }); + await boundaryReady; + for (let turns = 0; turns < 4 && !heartbeatAssigned; turns += 1) await new Promise((resolve) => setImmediate(resolve)); + assert.equal(heartbeatAssigned, true); heartbeat(); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(readKinds, ['progress']); + assert.match(lines.join(''), /Running tool: Bash\./); assert.doesNotMatch(lines.join(''), /PRIVATE_COMMAND/); + + releaseCompletion(); const output = await execution; + assert.equal(output.result, 'authoritative result'); assert.deepEqual(readKinds, ['progress', 'final']); +}); + test('executor surfaces terminal storage failure instead of silently leaving active state', async () => { const { root, workspace, store } = await setup(); const job = await store.reserveJob({ workspace, ...reservation }); const storageError = new PluginError('JSON_WRITE_FAILED', 'terminal write failed', { category: 'storage', remedy: 'retry recovery' }); const wrapped = { ...store, finishJob: async () => { throw storageError; } }; @@ -784,6 +847,31 @@ test('preview persistence failure stays observational while writer and exact res assert.match(lines.join(''), /ZCode started the delegated turn/); }); +test('subscription acknowledgement waits for a structurally valid public identifier', async () => { + const { root, workspace, store } = await setup(); const job = await store.reserveJob({ workspace, ...reservation }); + /** @type {any[]} */ const probes = []; + const wrapped = { + ...store, + updateJobProgressProbe: async (/** @type {string} */ workspaceArg, /** @type {string} */ jobId, /** @type {any} */ probe) => { + probes.push(probe); return store.updateJobProgressProbe(workspaceArg, jobId, probe); + }, + }; + const client = { + createSession: async () => ({ session: { sessionId: 'zs-bad-conversation-ack' }, settings: { model: { current: { providerId: 'p', modelId: 'm' }, available: [] } } }), + subscribeConversation: async () => ({ subscriptionId: 'subscription-\u0085-secret', unsubscribe: async () => {} }), + setPermissionHandler: () => {}, subscribe: silentSubscribe, + send: async () => ({ inputId: 'input-bad-conversation-ack', stateRevision: 1 }), waitForCompletion: async () => {}, + readSession: async () => ({ messages: [{ info: { role: 'assistant', messageId: 'assistant-bad-conversation-ack', parentMessageId: 'input-bad-conversation-ack' }, parts: [{ type: 'text', text: 'done' }] }] }), close: async () => {}, + }; + const result = await executeJob({ + job, workspace, dataRoot: join(root, 'data'), store: wrapped, client, task: 'task', + progressDependencies: { setInterval: () => ({ unref() {} }), clearInterval: () => {} }, + }); + assert.equal(result.result, 'done'); + assert.ok(probes.length > 0); + assert.equal(probes.some((probe) => probe.subscriptionAcknowledged), false); +}); + test('a never-settling conversation unsubscribe cannot block authoritative success', { timeout: 10_000 }, async () => { const { root, workspace, store } = await setup(); const job = await store.reserveJob({ workspace, ...reservation }); /** @type {(value:any)=>void} */ let signalAuthoritativeSuccess = () => {}; diff --git a/tests/plugin-contracts.test.mjs b/tests/plugin-contracts.test.mjs index a7611307..ccc84fea 100644 --- a/tests/plugin-contracts.test.mjs +++ b/tests/plugin-contracts.test.mjs @@ -240,3 +240,27 @@ test('package test scripts do not depend on shell glob expansion', () => { 'node --test tests/integration/plugin-layout.test.mjs', ); }); + +test('conversation compatibility progress never parses raw session logs or synthesizes conversation frames', () => { + for (const relativePath of ['scripts/lib/progress.mjs', 'scripts/lib/conversation-progress.mjs', 'scripts/lib/session-progress.mjs']) { + const source = readFileSync(new URL(relativePath, root), 'utf8'); + assert.doesNotMatch(source, /(?:readFile|createReadStream).*zcode/si, `${relativePath} must not parse raw ZCode logs`); + } + assert.doesNotMatch(readFileSync(new URL('scripts/lib/session-progress.mjs', root), 'utf8'), /v4\/conversation\/frame/, 'session fallback must not synthesize conversation frames'); +}); + +test('marketplace runtime mirrors the progress compatibility implementation byte for byte', () => { + for (const relativePath of [ + 'scripts/lib/conversation-progress.mjs', + 'scripts/lib/progress.mjs', + 'scripts/lib/render.mjs', + 'scripts/lib/review.mjs', + 'scripts/lib/session-progress.mjs', + 'scripts/lib/state.mjs', + 'scripts/zcode-companion.mjs', + ]) { + const source = readFileSync(new URL(relativePath, root)); + const marketplace = readFileSync(new URL(`marketplace/plugins/zcode/${relativePath}`, root)); + assert.deepEqual(marketplace, source, `${relativePath} marketplace runtime must be byte-identical to source`); + } +}); diff --git a/tests/process-zcode.test.mjs b/tests/process-zcode.test.mjs index c29b15c1..9cb54505 100644 --- a/tests/process-zcode.test.mjs +++ b/tests/process-zcode.test.mjs @@ -319,3 +319,47 @@ test('fake peer stop cancels the pending completion before acknowledging stop', assert.deepEqual(frames.map((frame) => frame.id), [1, 2]); assert.equal(frames.some((frame) => frame.method === 'state.updated'), false); }); + +test('fake peer completion waits for the exact progress-dispatch gate nonce', async () => { + const directory = await mkdtemp(join(tmpdir(), 'zcode-progress-dispatch-gate-')); + const gate = join(directory, 'gate.json'); const reached = join(directory, 'reached.json'); + const nonce = 'a'.repeat(64); const staleNonce = 'b'.repeat(64); + await writeFile(gate, JSON.stringify({ version: 1, nonce, state: 'held' })); + const peer = spawn(process.execPath, [fakeFixture], { + env: { ...process.env, FAKE_ZCODE_PROGRESS_DISPATCH_GATE: gate, FAKE_ZCODE_PROGRESS_DISPATCH_GATE_NONCE: nonce, FAKE_ZCODE_PROGRESS_DISPATCH_GATE_REACHED: reached, FAKE_ZCODE_COMPLETION_DELAY_MS: '0' }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + let stdout = ''; let stderr = ''; + peer.stdout.setEncoding('utf8'); peer.stdout.on('data', (chunk) => { stdout += chunk; }); + peer.stderr.setEncoding('utf8'); peer.stderr.on('data', (chunk) => { stderr += chunk; }); + const waitForGateChecks = async (minimum) => { + const deadline = Date.now() + 2_000; + while (Date.now() < deadline) { + const value = await readFile(reached, 'utf8').then(JSON.parse).catch(() => null); + if (value?.version === 1 && value.nonce === nonce && value.checks >= minimum) return; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + assert.fail(`fake progress gate did not reach check ${minimum}`); + }; + const waitForCompletion = async () => { + const deadline = Date.now() + 2_000; + while (Date.now() < deadline) { + if (stdout.includes('"method":"state.updated"')) return; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + assert.fail('fake progress gate never released completion'); + }; + try { + await new Promise((resolve, reject) => { peer.once('spawn', resolve); peer.once('error', reject); }); + peer.stdin.write(`${JSON.stringify({ id: 1, method: 'session/send', params: { sessionId: 'progress-gate-session', inputId: 'input-1' } })}\n`); + await waitForGateChecks(1); assert.equal(stdout.includes('"method":"state.updated"'), false); + await writeFile(gate, JSON.stringify({ version: 1, nonce: staleNonce, state: 'release' })); + await waitForGateChecks(2); assert.equal(stdout.includes('"method":"state.updated"'), false); + await writeFile(gate, JSON.stringify({ version: 1, nonce, state: 'release' })); + await waitForCompletion(); peer.stdin.end(); + assert.equal(await new Promise((resolve) => peer.once('exit', resolve)), 0, stderr); + } finally { + if (peer.exitCode === null && peer.signalCode === null) peer.kill('SIGKILL'); + await rm(directory, { recursive: true, force: true }); + } +}); diff --git a/tests/progress.test.mjs b/tests/progress.test.mjs index 021ed8ba..691a9199 100644 --- a/tests/progress.test.mjs +++ b/tests/progress.test.mjs @@ -42,6 +42,546 @@ test('exports fixed progress bounds and phases', () => { assert.equal(progressModule.MAX_PROGRESS_DIAGNOSTIC_KINDS, 8); }); +test('snapshot reads cannot start before accepted-boundary activation and begin on the first heartbeat', async () => { + const lines = []; let heartbeat = () => {}; let reads = 0; + const reporter = progressModule.createProgressReporter({ + sessionId: 'session-a', deferred: true, write: (line) => lines.push(line), now: () => observedAt, + setInterval: (callback) => { heartbeat = callback; return { unref() {} }; }, clearInterval: () => {}, + }); + reporter.activate(notification('prompt_started')); + heartbeat(); await new Promise((resolve) => setImmediate(resolve)); + assert.equal(reads, 0); assert.equal(reporter.probeSnapshot().state, 'probing'); + + reporter.activateAcceptedBoundary({ + readSnapshot: async () => { reads += 1; return { runtime: { stateRevision: 1 }, messages: [] }; }, + describer: { observe: async (_snapshot, seenAt) => [{ phase: 'running', message: 'Running tool: Bash.', observedAt: seenAt }] }, + }); + heartbeat(); await new Promise((resolve) => setImmediate(resolve)); await reporter.flush(); + + assert.equal(reads, 1); assert.equal(reporter.probeSnapshot().state, 'snapshot-fallback'); + assert.deepEqual(lines, [ + '[zcode] ZCode started the delegated turn.\n', + '[zcode] ZCode conversation frames were unavailable; using bounded session progress.\n', + '[zcode] Running tool: Bash.\n', + ]); + reporter.close(); +}); + +test('keeps one snapshot read in flight and accepted online recovery discards its late result', async () => { + const lines = []; let heartbeat = () => {}; let reads = 0; let resolveRead = () => {}; + const delayedRead = new Promise((resolve) => { resolveRead = resolve; }); + const reporter = progressModule.createProgressReporter({ + sessionId: 'session-a', write: (line) => lines.push(line), now: () => observedAt, + describeNotification: async () => ({ disposition: 'accepted', phase: 'online', events: [] }), + setInterval: (callback) => { heartbeat = callback; return { unref() {} }; }, clearInterval: () => {}, + }); + reporter.activateAcceptedBoundary({ + readSnapshot: () => { reads += 1; return delayedRead; }, + describer: { observe: async (_snapshot, seenAt) => [{ phase: 'running', message: 'LATE_PRIVATE_PROGRESS', observedAt: seenAt }] }, + }); + reporter.activateCompatibilityBoundary(); heartbeat(); heartbeat(); + await Promise.resolve(); + assert.equal(reads, 1); + + reporter.observe(conversationFrame({ deltas: [] })); await reporter.flush(); + assert.equal(reporter.probeSnapshot().state, 'online'); + resolveRead({ runtime: { stateRevision: 1 }, messages: [] }); + await new Promise((resolve) => setImmediate(resolve)); heartbeat(); await reporter.flush(); + + assert.equal(reads, 1); + assert.doesNotMatch(lines.join(''), /LATE_PRIVATE_PROGRESS/); + reporter.close(); +}); + +test('an early rejection-triggered snapshot read claims the adjacent heartbeat window', async () => { + let heartbeat = () => {}; let reads = 0; + const reasons = ['wire-version', 'envelope-shape', 'sequence', 'topic']; + const reporter = progressModule.createProgressReporter({ + sessionId: 'session-a', write: () => {}, now: () => observedAt, + describeNotification: async () => ({ disposition: 'rejected', reason: reasons.shift(), events: [] }), + setInterval: (callback) => { heartbeat = callback; return { unref() {} }; }, clearInterval: () => {}, + }); + reporter.activateAcceptedBoundary({ + readSnapshot: async () => { reads += 1; return { runtime: { stateRevision: 1 }, messages: [] }; }, + describer: { observe: async () => [] }, + }); + reporter.activate(notification('prompt_started')); + for (let index = 0; index < 4; index += 1) reporter.observe({ method: 'v4/conversation/frame', index }); + await reporter.flush(); await new Promise((resolve) => setImmediate(resolve)); + assert.equal(reads, 1); + + heartbeat(); await new Promise((resolve) => setImmediate(resolve)); + assert.equal(reads, 1); + reporter.observe(notification('tool_started')); + heartbeat(); await new Promise((resolve) => setImmediate(resolve)); + assert.equal(reads, 2); + heartbeat(); await new Promise((resolve) => setImmediate(resolve)); + assert.equal(reads, 3); + reporter.close(); +}); + +test('snapshot read failure degrades exactly once without exposing its rejection', async () => { + const lines = []; const diagnostics = []; let reads = 0; + const reporter = progressModule.createProgressReporter({ + sessionId: 'session-a', write: (line) => lines.push(line), now: () => observedAt, + onDiagnostic: ({ kind }) => diagnostics.push(kind), + }); + reporter.activateAcceptedBoundary({ + readSnapshot: async () => { reads += 1; throw new Error('PRIVATE_READ_REJECTION'); }, + describer: { observe: async () => [] }, + }); + reporter.activateCompatibilityBoundary(); + await new Promise((resolve) => setImmediate(resolve)); await reporter.flush(); + + assert.equal(reads, 1); assert.equal(reporter.probeSnapshot().state, 'lifecycle-only'); + assert.deepEqual(diagnostics, ['conversation-snapshot-fallback', 'conversation-lifecycle-only']); + assert.equal(lines.filter((line) => /semantic progress is unavailable/.test(line)).length, 1); + assert.doesNotMatch(lines.join(''), /PRIVATE_READ_REJECTION/); + reporter.close(); +}); + +test('stopAccepting fences a late snapshot read rejection without a cleanup diagnostic', async () => { + const lines = []; const diagnostics = []; let rejectRead = () => {}; + const pendingRead = new Promise((_resolve, reject) => { rejectRead = reject; }); + const reporter = progressModule.createProgressReporter({ + sessionId: 'session-a', write: (line) => lines.push(line), now: () => observedAt, + onDiagnostic: ({ kind }) => diagnostics.push(kind), + }); + reporter.activateAcceptedBoundary({ + readSnapshot: () => pendingRead, + describer: { observe: async () => [] }, + }); + reporter.activate(notification('prompt_started')); + reporter.activateCompatibilityBoundary(); await Promise.resolve(); + assert.equal(reporter.probeSnapshot().state, 'snapshot-fallback'); + + reporter.stopAccepting(); rejectRead(new Error('PRIVATE_LATE_READ_REJECTION')); + await new Promise((resolve) => setImmediate(resolve)); await reporter.flush(); + + assert.equal(reporter.probeSnapshot().state, 'snapshot-fallback'); + assert.deepEqual(diagnostics, ['conversation-snapshot-fallback']); + assert.doesNotMatch(lines.join(''), /semantic progress is unavailable|PRIVATE_LATE_READ_REJECTION/); + reporter.close(); +}); + +test('stopAccepting fences late snapshot description and normalization failures', async () => { + for (const mode of ['rejection', 'invalid-normalization']) { + const lines = []; const diagnostics = []; let settleDescription = () => {}; + let markDescriptionStarted = () => {}; + const descriptionStarted = new Promise((resolve) => { markDescriptionStarted = resolve; }); + const description = new Promise((resolve, reject) => { + settleDescription = () => mode === 'rejection' + ? reject(new Error('PRIVATE_LATE_DESCRIPTION_REJECTION')) + : resolve([{ phase: 'running', message: 'PRIVATE\nINVALID', observedAt }]); + }); + const reporter = progressModule.createProgressReporter({ + sessionId: 'session-a', write: (line) => lines.push(line), now: () => observedAt, + onDiagnostic: ({ kind }) => diagnostics.push(kind), + }); + reporter.activateAcceptedBoundary({ + readSnapshot: async () => ({ runtime: { stateRevision: 1 }, messages: [] }), + describer: { observe: () => { markDescriptionStarted(); return description; } }, + }); + reporter.activate(notification('prompt_started')); + reporter.activateCompatibilityBoundary(); await descriptionStarted; + + reporter.stopAccepting(); settleDescription(); + await new Promise((resolve) => setImmediate(resolve)); await reporter.flush(); + + assert.equal(reporter.probeSnapshot().state, 'snapshot-fallback', mode); + assert.deepEqual(diagnostics, ['conversation-snapshot-fallback'], mode); + assert.doesNotMatch(lines.join(''), /semantic progress is unavailable|PRIVATE/, mode); + reporter.close(); + } +}); + +test('invalid snapshot normalization degrades once instead of silently accepting unsafe events', async () => { + const lines = []; + const reporter = progressModule.createProgressReporter({ sessionId: 'session-a', write: (line) => lines.push(line), now: () => observedAt }); + reporter.activateAcceptedBoundary({ + readSnapshot: async () => ({ runtime: { stateRevision: 1 }, messages: [] }), + describer: { observe: async () => [{ phase: 'running', message: 'PRIVATE\nUNSAFE', observedAt }] }, + }); + reporter.activateCompatibilityBoundary(); await new Promise((resolve) => setImmediate(resolve)); await reporter.flush(); + assert.equal(reporter.probeSnapshot().state, 'lifecycle-only'); + assert.equal(lines.filter((line) => /semantic progress is unavailable/.test(line)).length, 1); + assert.doesNotMatch(lines.join(''), /PRIVATE|UNSAFE/); + reporter.close(); +}); + +test('tracks bounded structural compatibility and activates fallback only at an explicit boundary', async () => { + const probes = []; const diagnostics = []; const lines = []; let heartbeat; + const results = [ + { disposition: 'accepted', phase: 'initial', events: [] }, + { disposition: 'rejected', reason: 'row-shape', events: [] }, + { disposition: 'accepted', phase: 'online', events: [] }, + ]; + const reporter = progressModule.createProgressReporter({ + sessionId: 'session-a', write: (line) => lines.push(line), + describeNotification: async () => results.shift(), + persistProbe: async (probe) => probes.push(probe), + activateSnapshotFallback: () => () => {}, + onDiagnostic: ({ kind }) => diagnostics.push(kind), + now: () => observedAt, + setInterval: (callback) => { heartbeat = callback; return { unref() {} }; }, clearInterval: () => {}, + }); + reporter.markConversationSubscribed(); + reporter.observe(conversationFrame({ deliveryKind: 'initial', deltas: [] })); + await reporter.flush(); + assert.equal(probes.at(-1).state, 'probing'); + assert.equal(probes.at(-1).acceptedInitial, 1); + assert.equal(probes.at(-1).subscriptionAcknowledged, true); + heartbeat(); await reporter.flush(); + assert.equal(probes.at(-1).state, 'snapshot-fallback'); + assert.equal(probes.at(-1).snapshotFallbackActive, true); + assert.deepEqual(diagnostics, ['conversation-snapshot-fallback']); + assert.deepEqual(lines, ['[zcode] ZCode conversation frames were unavailable; using bounded session progress.\n']); + reporter.observe(conversationFrame({ deltas: [toolRow()] })); + reporter.observe(conversationFrame({ ordinal: 2, deltas: [] })); + await reporter.flush(); + assert.equal(probes.at(-1).rejected['row-shape'], 1); + assert.equal(probes.at(-1).acceptedOnline, 1); + assert.equal(probes.at(-1).state, 'online'); + assert.equal(probes.at(-1).snapshotFallbackActive, false); + assert.equal(reporter.activateCompatibilityBoundary(), false); + assert.deepEqual(diagnostics, ['conversation-snapshot-fallback']); + reporter.close(); +}); + +test('an accepted zero-event online frame recovers lifecycle-only without reactivating fallback', async () => { + const diagnostics = []; + const reporter = progressModule.createProgressReporter({ + sessionId: 'session-a', describeNotification: async () => ({ disposition: 'accepted', phase: 'online', events: [] }), + onDiagnostic: ({ kind }) => diagnostics.push(kind), now: () => observedAt, + }); + assert.equal(reporter.activateCompatibilityBoundary(), true); + assert.equal(reporter.probeSnapshot().state, 'lifecycle-only'); + reporter.observe(conversationFrame({ deltas: [] })); await reporter.flush(); + assert.equal(reporter.probeSnapshot().state, 'online'); + assert.equal(reporter.probeSnapshot().snapshotFallbackUnavailable, false); + assert.equal(reporter.activateCompatibilityBoundary(), false); + assert.deepEqual(diagnostics, ['conversation-lifecycle-only']); + reporter.close(); +}); + +test('fallback activation requires a cleanup callback', async () => { + for (const activation of [true, undefined, { cleanup() {} }]) { + const diagnostics = []; const lines = []; + const reporter = progressModule.createProgressReporter({ + sessionId: 'session-a', activateSnapshotFallback: () => activation, + write: (line) => lines.push(line), onDiagnostic: ({ kind }) => diagnostics.push(kind), now: () => observedAt, + setInterval: () => ({ unref() {} }), clearInterval: () => {}, + }); + assert.equal(reporter.activateCompatibilityBoundary(), true); + await reporter.flush(); + assert.equal(reporter.probeSnapshot().state, 'lifecycle-only'); + assert.equal(reporter.probeSnapshot().snapshotFallbackActive, false); + assert.equal(reporter.probeSnapshot().snapshotFallbackUnavailable, true); + assert.deepEqual(diagnostics, ['conversation-lifecycle-only']); + assert.deepEqual(lines, ['[zcode] ZCode semantic progress is unavailable; lifecycle updates will continue.\n']); + reporter.close(); + } +}); + +test('rejected fallback activation is observed while degrading to lifecycle-only', async () => { + const unhandled = []; const diagnostics = []; const lines = []; + const onUnhandled = (error) => unhandled.push(error); + process.on('unhandledRejection', onUnhandled); + const reporter = progressModule.createProgressReporter({ + sessionId: 'session-a', activateSnapshotFallback: () => Promise.reject(new Error('PRIVATE_ACTIVATION_REJECTION')), + write: (line) => lines.push(line), onDiagnostic: ({ kind }) => diagnostics.push(kind), now: () => observedAt, + setInterval: () => ({ unref() {} }), clearInterval: () => {}, + }); + try { + assert.equal(reporter.activateCompatibilityBoundary(), true); + await reporter.flush(); await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(unhandled, []); + assert.equal(reporter.probeSnapshot().state, 'lifecycle-only'); + assert.equal(reporter.probeSnapshot().snapshotFallbackActive, false); + assert.equal(reporter.probeSnapshot().snapshotFallbackUnavailable, true); + assert.deepEqual(diagnostics, ['conversation-lifecycle-only']); + assert.deepEqual(lines, ['[zcode] ZCode semantic progress is unavailable; lifecycle updates will continue.\n']); + } finally { + process.off('unhandledRejection', onUnhandled); + reporter.close(); + } +}); + +test('never-settling Promise fallback activation is detached from bounded flush and close', async () => { + const lines = []; const activation = new Promise(() => {}); + const reporter = progressModule.createProgressReporter({ + sessionId: 'session-a', activateSnapshotFallback: () => activation, + write: (line) => lines.push(line), now: () => observedAt, + setInterval: () => ({ unref() {} }), clearInterval: () => {}, + }); + assert.equal(reporter.activateCompatibilityBoundary(), true); + const outcome = await Promise.race([ + reporter.flush(Date.now() + 25).then(() => 'flushed'), + new Promise((resolve) => { const timer = setTimeout(() => resolve('timed-out'), 100); timer.unref?.(); }), + ]); + assert.equal(outcome, 'flushed'); + assert.equal(reporter.probeSnapshot().state, 'lifecycle-only'); + assert.deepEqual(lines, ['[zcode] ZCode semantic progress is unavailable; lifecycle updates will continue.\n']); + assert.doesNotThrow(() => reporter.close()); +}); + +test('hostile fallback activation then access stays observational', async () => { + let thenReads = 0; let catchReads = 0; const unhandled = []; + const activation = {}; + Object.defineProperties(activation, { + then: { get() { thenReads += 1; throw new Error('PRIVATE_HOSTILE_THEN'); } }, + catch: { get() { catchReads += 1; throw new Error('PRIVATE_HOSTILE_CATCH'); } }, + }); + const onUnhandled = (error) => unhandled.push(error); + process.on('unhandledRejection', onUnhandled); + const reporter = progressModule.createProgressReporter({ + sessionId: 'session-a', activateSnapshotFallback: () => activation, now: () => observedAt, + }); + try { + assert.equal(reporter.activateCompatibilityBoundary(), true); + await reporter.flush(); await new Promise((resolve) => setImmediate(resolve)); + assert.equal(thenReads, 1); assert.equal(catchReads, 0); assert.deepEqual(unhandled, []); + assert.equal(reporter.probeSnapshot().state, 'lifecycle-only'); + assert.equal(reporter.probeSnapshot().snapshotFallbackUnavailable, true); + } finally { + process.off('unhandledRejection', onUnhandled); + reporter.close(); + } +}); + +test('accepted online recovery invokes an activated snapshot fallback cleanup once', async () => { + let cleanupCalls = 0; + const reporter = progressModule.createProgressReporter({ + sessionId: 'session-a', + activateSnapshotFallback: () => () => { cleanupCalls += 1; }, + describeNotification: async () => ({ disposition: 'accepted', phase: 'online', events: [] }), + now: () => observedAt, + }); + assert.equal(reporter.activateCompatibilityBoundary(), true); + assert.equal(reporter.probeSnapshot().state, 'snapshot-fallback'); + reporter.observe(conversationFrame({ deltas: [] })); await reporter.flush(); + assert.equal(reporter.probeSnapshot().state, 'online'); assert.equal(cleanupCalls, 1); + reporter.close(); assert.equal(cleanupCalls, 1); +}); + +test('close invokes an activated snapshot fallback cleanup once', () => { + let cleanupCalls = 0; + const reporter = progressModule.createProgressReporter({ + sessionId: 'session-a', activateSnapshotFallback: () => () => { cleanupCalls += 1; }, now: () => observedAt, + }); + assert.equal(reporter.activateCompatibilityBoundary(), true); + reporter.close(); reporter.close(); + assert.equal(cleanupCalls, 1); +}); + +test('throwing and non-settling fallback cleanup stay observational and bounded', async () => { + let throwingCalls = 0; + const throwing = progressModule.createProgressReporter({ + sessionId: 'session-a', + activateSnapshotFallback: () => () => { throwingCalls += 1; throw new Error('private fallback cleanup failure'); }, + describeNotification: async () => ({ disposition: 'accepted', phase: 'online', events: [] }), now: () => observedAt, + }); + throwing.activateCompatibilityBoundary(); throwing.observe(conversationFrame({ deltas: [] })); + await throwing.flush(); + assert.equal(throwing.probeSnapshot().state, 'online'); assert.equal(throwingCalls, 1); + throwing.close(); + + let nonSettlingCalls = 0; + const nonSettling = progressModule.createProgressReporter({ + sessionId: 'session-a', + activateSnapshotFallback: () => () => { nonSettlingCalls += 1; return new Promise(() => {}); }, + now: () => observedAt, + }); + nonSettling.activateCompatibilityBoundary(); + const started = Date.now(); nonSettling.close(); + assert.ok(Date.now() - started < 100); assert.equal(nonSettlingCalls, 1); +}); + +test('an accepted zero-event online frame marks the probe online and blocks fallback', async () => { + const diagnostics = []; + const reporter = progressModule.createProgressReporter({ + sessionId: 'session-a', describeNotification: async () => ({ disposition: 'accepted', phase: 'online', events: [] }), + onDiagnostic: ({ kind }) => diagnostics.push(kind), now: () => observedAt, + }); + reporter.observe(conversationFrame({ deltas: [] })); await reporter.flush(); + assert.deepEqual(reporter.probeSnapshot(), { + state: 'online', subscriptionAcknowledged: false, framesReceived: 1, + acceptedInitial: 0, acceptedOnline: 1, acceptedRecovery: 0, + rejected: { 'wire-version': 0, 'envelope-shape': 0, sequence: 0, topic: 0, 'row-kind': 0, 'row-shape': 0 }, + snapshotFallbackActive: false, snapshotFallbackUnavailable: false, + }); + assert.equal(reporter.activateCompatibilityBoundary(), false); + assert.deepEqual(diagnostics, []); + reporter.close(); +}); + +test('the fixed fourth structural rejection activates lifecycle-only exactly once', async () => { + const diagnostics = []; + const reasons = ['wire-version', 'envelope-shape', 'sequence', 'topic']; + const reporter = progressModule.createProgressReporter({ + sessionId: 'session-a', describeNotification: async () => ({ disposition: 'rejected', reason: reasons.shift(), events: [] }), + activateSnapshotFallback: () => false, + onDiagnostic: ({ kind }) => diagnostics.push(kind), now: () => observedAt, + }); + for (let index = 0; index < 4; index += 1) reporter.observe({ method: 'v4/conversation/frame', index }); + await reporter.flush(); + assert.equal(reporter.probeSnapshot().state, 'lifecycle-only'); + assert.deepEqual(diagnostics, ['conversation-lifecycle-only']); + reporter.close(); +}); + +test('stopAccepting fences a held structural rejection from every progress mutation', async () => { + const diagnostics = []; const probes = []; let fallbackCalls = 0; + let releaseHeld = () => {}; let markHeldStarted = () => {}; + const heldStarted = new Promise((resolve) => { markHeldStarted = resolve; }); + const held = new Promise((resolve) => { releaseHeld = () => resolve({ disposition: 'rejected', reason: 'sequence', events: [] }); }); + let descriptions = 0; + const reporter = progressModule.createProgressReporter({ + sessionId: 'session-a', + describeNotification: async () => { + descriptions += 1; + if (descriptions < 4) return { disposition: 'rejected', reason: 'sequence', events: [] }; + markHeldStarted(); return held; + }, + activateSnapshotFallback: () => { fallbackCalls += 1; return false; }, + persistProbe: async (probe) => probes.push(probe), + onDiagnostic: ({ kind }) => diagnostics.push(kind), + now: () => observedAt, + }); + for (let index = 0; index < 3; index += 1) { + reporter.observe({ method: 'v4/conversation/frame', index }); + await reporter.flush(); + } + reporter.observe({ method: 'v4/conversation/frame', index: 3 }); + await heldStarted; + const beforeProbe = reporter.probeSnapshot(); const beforePersists = probes.length; + + reporter.stopAccepting(); releaseHeld(); + await new Promise((resolve) => setImmediate(resolve)); await reporter.flush(); + + assert.deepEqual(reporter.probeSnapshot(), beforeProbe); + assert.equal(probes.length, beforePersists); + assert.equal(fallbackCalls, 0); + assert.deepEqual(diagnostics, []); + reporter.close(); +}); + +test('stopAccepting silently settles a held descriptor rejection without retrying it', async () => { + const diagnostics = []; const probes = []; const lines = []; const persisted = []; let fallbackCalls = 0; + let rejectHeld = () => {}; let markHeldStarted = () => {}; + const heldStarted = new Promise((resolve) => { markHeldStarted = resolve; }); + const held = new Promise((_resolve, reject) => { rejectHeld = reject; }); + let descriptions = 0; + const reporter = progressModule.createProgressReporter({ + sessionId: 'session-a', + describeNotification: () => { + descriptions += 1; + if (descriptions > 1) throw new Error('a rejected descriptor must never restart'); + markHeldStarted(); return held; + }, + activateSnapshotFallback: () => { fallbackCalls += 1; return false; }, + persistProbe: async (probe) => probes.push(probe), + persist: async (event) => persisted.push(event), + write: (line) => lines.push(line), + onDiagnostic: ({ kind }) => diagnostics.push(kind), + now: () => observedAt, + setInterval: () => ({ unref() {} }), clearInterval: () => {}, + }); + reporter.observe({ method: 'v4/conversation/frame', index: 0 }); await heldStarted; + const beforeProbe = reporter.probeSnapshot(); const beforePersists = probes.length; + + reporter.stopAccepting(); rejectHeld(new Error('PRIVATE_LATE_DESCRIPTOR_REJECTION')); + const started = Date.now(); await reporter.flush(Date.now() + 250); + + assert.ok(Date.now() - started < 250); + assert.equal(descriptions, 1); + assert.deepEqual(reporter.probeSnapshot(), beforeProbe); + assert.equal(probes.length, beforePersists); + assert.equal(fallbackCalls, 0); + assert.deepEqual(diagnostics, []); + assert.deepEqual(lines, []); assert.deepEqual(persisted, []); + reporter.close(); +}); + +test('falls back to lifecycle-only with one fixed diagnostic when no snapshot capability exists', async () => { + const probes = []; const diagnostics = []; + const reporter = progressModule.createProgressReporter({ + sessionId: 'session-a', describeNotification: async () => ({ disposition: 'rejected', reason: 'topic', events: [] }), + persistProbe: (probe) => probes.push(probe), onDiagnostic: ({ kind }) => diagnostics.push(kind), now: () => observedAt, + }); + reporter.markConversationSubscribed(); + reporter.activateCompatibilityBoundary(); reporter.activateCompatibilityBoundary(); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(probes.at(-1).state, 'lifecycle-only'); + assert.equal(probes.at(-1).snapshotFallbackUnavailable, true); + assert.deepEqual(diagnostics, ['conversation-lifecycle-only']); + reporter.close(); +}); + +test('coalesces probe persistence to one bounded pending snapshot under a frame flood', async () => { + const probes = []; let releaseFirst; + const first = new Promise((resolve) => { releaseFirst = resolve; }); + const reporter = progressModule.createProgressReporter({ + sessionId: 'session-a', describeNotification: () => new Promise(() => {}), + persistProbe: (probe) => { probes.push(probe); return probes.length === 1 ? first : undefined; }, + now: () => observedAt, setInterval: () => ({ unref() {} }), clearInterval: () => {}, + }); + reporter.markConversationSubscribed(); + for (let index = 0; index < 20; index += 1) reporter.observe({ method: 'v4/conversation/frame', index }); + assert.equal(probes.length, 1); + releaseFirst(); await new Promise((resolve) => setImmediate(resolve)); + assert.equal(probes.length, 2); + assert.equal(probes[1].framesReceived, 20); + reporter.close(); +}); + +test('reporter saturates received accepted and every structural rejection counter', async () => { + let result = { disposition: 'accepted', phase: 'initial', events: [] }; + const reporter = progressModule.createProgressReporter({ + sessionId: 'session-a', describeNotification: async () => result, now: () => observedAt, + }); + const observeUntilSaturated = async (nextResult) => { + result = nextResult; + for (let index = 0; index <= progressModule.MAX_PROGRESS_PROBE_COUNT; index += 1) { + reporter.observe({ method: 'v4/conversation/frame' }); + await new Promise((resolve) => setImmediate(resolve)); + } + }; + for (const phase of ['initial', 'online', 'recovery']) { + await observeUntilSaturated({ disposition: 'accepted', phase, events: [] }); + } + for (const reason of ['wire-version', 'envelope-shape', 'sequence', 'topic', 'row-kind', 'row-shape']) { + await observeUntilSaturated({ disposition: 'rejected', reason, events: [] }); + } + await reporter.flush(); + assert.deepEqual(reporter.probeSnapshot(), { + state: 'online', subscriptionAcknowledged: false, framesReceived: progressModule.MAX_PROGRESS_PROBE_COUNT, + acceptedInitial: progressModule.MAX_PROGRESS_PROBE_COUNT, + acceptedOnline: progressModule.MAX_PROGRESS_PROBE_COUNT, + acceptedRecovery: progressModule.MAX_PROGRESS_PROBE_COUNT, + rejected: Object.fromEntries(['wire-version', 'envelope-shape', 'sequence', 'topic', 'row-kind', 'row-shape'].map((reason) => [reason, progressModule.MAX_PROGRESS_PROBE_COUNT])), + snapshotFallbackActive: false, snapshotFallbackUnavailable: false, + }); + reporter.close(); +}); + +test('flush boundedly drains the latest coalesced probe snapshot before cleanup', async () => { + const probes = []; let releaseFirst; + const first = new Promise((resolve) => { releaseFirst = resolve; }); + const reporter = progressModule.createProgressReporter({ + sessionId: 'session-a', persistProbe: (probe) => { probes.push(probe); return probes.length === 1 ? first : undefined; }, + now: () => observedAt, setInterval: () => ({ unref() {} }), clearInterval: () => {}, + }); + reporter.markConversationSubscribed(); reporter.activateCompatibilityBoundary(); + let flushed = false; const flushing = reporter.flush().then(() => { flushed = true; }); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(flushed, false); + releaseFirst(); await flushing; + assert.equal(probes.length, 2); + assert.equal(probes[1].state, 'lifecycle-only'); + reporter.close(); +}); + test('normalizes known same-session activity to fixed public messages', () => { const cases = [ ['prompt_started', 'starting', 'ZCode started the delegated turn.'], @@ -221,6 +761,7 @@ test('duplicate activity refreshes the heartbeat clock without repeating output let currentTime = observedAt; const reporter = progressModule.createProgressReporter({ sessionId: 'session-a', + activateSnapshotFallback: () => false, write: (line) => lines.push(line), persist: async (event) => persisted.push(event), now: () => currentTime, @@ -235,8 +776,14 @@ test('duplicate activity refreshes the heartbeat clock without repeating output intervalCallback(); await reporter.flush(); - assert.deepEqual(lines, ['[zcode] ZCode tool work is still running.\n']); - assert.deepEqual(persisted, [{ phase: 'running', message: 'ZCode tool work is still running.', observedAt }]); + assert.deepEqual(lines, [ + '[zcode] ZCode tool work is still running.\n', + '[zcode] ZCode semantic progress is unavailable; lifecycle updates will continue.\n', + ]); + assert.deepEqual(persisted, [ + { phase: 'running', message: 'ZCode tool work is still running.', observedAt }, + { phase: 'waiting', message: 'ZCode semantic progress is unavailable; lifecycle updates will continue.', observedAt: currentTime }, + ]); reporter.close(); }); @@ -713,6 +1260,22 @@ test('does not create a heartbeat interval without a writer', () => { reporter.close(); }); +test('persistence-only probes reach the first-heartbeat compatibility boundary', async () => { + const probes = []; const diagnostics = []; let heartbeat; let intervalCalls = 0; + const reporter = progressModule.createProgressReporter({ + sessionId: 'session-a', persistProbe: (probe) => probes.push(probe), + activateSnapshotFallback: () => false, + onDiagnostic: ({ kind }) => diagnostics.push(kind), now: () => observedAt, + setInterval: (callback) => { intervalCalls += 1; heartbeat = callback; return { unref() {} }; }, + clearInterval: () => {}, + }); + assert.equal(intervalCalls, 1); + heartbeat(); await Promise.resolve(); + assert.equal(probes.at(-1).state, 'lifecycle-only'); + assert.deepEqual(diagnostics, ['conversation-lifecycle-only']); + reporter.close(); +}); + async function waitUntil(predicate) { for (let attempt = 0; attempt < 100; attempt += 1) { if (predicate()) return; diff --git a/tests/render-progress.test.mjs b/tests/render-progress.test.mjs index 13e000bd..78b64ae0 100644 --- a/tests/render-progress.test.mjs +++ b/tests/render-progress.test.mjs @@ -115,14 +115,33 @@ test('compact legacy and queued jobs show explicit missing progress placeholders }), `${id} queued review same-owner phase=— activity=—\n`); }); -test('JSON output remains structurally unchanged and redacted', () => { +test('JSON exposes only a valid exact-owner single-job probe while every other view redacts it', () => { + const progressProbe = { + state: 'online', subscriptionAcknowledged: true, framesReceived: 1, + acceptedInitial: 0, acceptedOnline: 1, acceptedRecovery: 0, + rejected: { 'wire-version': 0, 'envelope-shape': 0, sequence: 0, topic: 0, 'row-kind': 0, 'row-shape': 0 }, + snapshotFallbackActive: false, snapshotFallbackUnavailable: false, + }; const value = { - job: { id, status: 'running', phase: 'running', progressPreview: ['safe'] }, + job: { + id, status: 'running', phase: 'running', progressPreview: ['safe'], owned: true, owner: 'same-owner', progressProbe, + }, permissionSnapshot: { mode: 'workspace-write' }, nested: { executionCapability: 'secret', visible: true }, }; assert.deepEqual(JSON.parse(renderOutput(value, { json: true })), { - job: { id, status: 'running', phase: 'running', progressPreview: ['safe'] }, + job: { id, status: 'running', phase: 'running', progressPreview: ['safe'], owned: true, owner: 'same-owner', progressProbe }, nested: { visible: true }, }); + assert.doesNotMatch(renderOutput(value), /progressProbe|subscriptionAcknowledged|framesReceived/); + + const invalid = { ...value, job: { ...value.job, progressProbe: { ...progressProbe, marker: 'PROBE_INTERNAL' } } }; + assert.equal(Object.hasOwn(JSON.parse(renderOutput(invalid, { json: true })).job, 'progressProbe'), false); + assert.doesNotMatch(renderOutput(invalid, { json: true }), /PROBE_INTERNAL/); + + for (const hidden of [ + { job: { ...value.job, owned: undefined, owner: undefined } }, + { jobs: [value.job] }, + { job: { ...value.job, owned: undefined, owner: undefined, hasOwner: true } }, + ]) assert.equal(Object.hasOwn((JSON.parse(renderOutput(hidden, { json: true })).job ?? JSON.parse(renderOutput(hidden, { json: true })).jobs[0]), 'progressProbe'), false); }); diff --git a/tests/session-progress.test.mjs b/tests/session-progress.test.mjs new file mode 100644 index 00000000..dbe51c7e --- /dev/null +++ b/tests/session-progress.test.mjs @@ -0,0 +1,214 @@ +// @ts-nocheck +import assert from 'node:assert/strict'; +import { mkdtemp, readFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { createSessionProgressDescriber } from '../scripts/lib/session-progress.mjs'; + +const observedAt = '2026-08-15T00:00:00.000Z'; + +function userMessage(messageId, overrides = {}) { + return { info: { role: 'user', messageId, ...overrides }, parts: [] }; +} + +function assistantMessage(messageId, parentMessageId, parts, overrides = {}) { + return { info: { role: 'assistant', messageId, parentMessageId, ...overrides }, parts }; +} + +function toolPart(callId, tool, status, input = {}, state = {}) { + return { type: 'tool', callId, tool, state: { status, input, ...state } }; +} + +function snapshot(messages, stateRevision = 8) { + return { runtime: { stateRevision }, messages }; +} + +test('describes one safe start from a direct current-turn assistant tool part', async () => { + const workspace = await mkdtemp(join(tmpdir(), 'zcode-session-progress-')); + const describer = await createSessionProgressDescriber({ + workspace, + turnBoundary: { inputId: 'accepted-input', stateRevision: 7, beforeMessageIds: new Set(['old-user', 'old-assistant']) }, + }); + + const events = await describer.observe(snapshot([ + userMessage('old-user'), + assistantMessage('old-assistant', 'old-user', [toolPart('old-call', 'Bash', 'running', { command: 'PRIVATE_OLD' })]), + userMessage('accepted-input'), + assistantMessage('current-assistant', 'accepted-input', [toolPart('current-call', 'Bash', 'running', { command: 'npm test' })]), + ]), observedAt); + + assert.deepEqual(events, [{ phase: 'running', message: 'Running tool: Bash.', observedAt }]); +}); + +test('never renders assistant prose, reasoning, commands, tool results, errors, metadata, or ids', async () => { + const workspace = await mkdtemp(join(tmpdir(), 'zcode-session-progress-')); + const describer = await createSessionProgressDescriber({ + workspace, + turnBoundary: { inputId: 'accepted-input', stateRevision: 7, beforeMessageIds: new Set() }, + }); + const privateValues = ['ASSISTANT_PRIVATE', 'REASONING_PRIVATE', 'COMMAND_PRIVATE', 'OUTPUT_PRIVATE', 'ERROR_PRIVATE', 'METADATA_PRIVATE', 'CALL_PRIVATE']; + const events = await describer.observe(snapshot([ + userMessage('accepted-input'), + assistantMessage('assistant-private', 'accepted-input', [ + { type: 'text', text: privateValues[0] }, + { type: 'reasoning', text: privateValues[1] }, + { + ...toolPart(privateValues[6], 'Bash', 'completed', { command: privateValues[2], arbitrary: 'INPUT_PRIVATE' }, { + output: privateValues[3], error: privateValues[4], metadata: { secret: privateValues[5] }, startedAt: 10, completedAt: 20, + }), + metadata: { secret: privateValues[5] }, + }, + ]), + ]), observedAt); + + assert.deepEqual(events, [{ phase: 'running', message: 'Bash completed (10ms).', observedAt }]); + assert.doesNotMatch(JSON.stringify(events), new RegExp(privateValues.join('|'))); +}); + +test('fails closed for malformed duplicate message relationships instead of broadening the scan', async () => { + const workspace = await mkdtemp(join(tmpdir(), 'zcode-session-progress-')); + const describer = await createSessionProgressDescriber({ + workspace, + turnBoundary: { inputId: 'accepted-input', stateRevision: 7, beforeMessageIds: new Set() }, + }); + const events = await describer.observe(snapshot([ + userMessage('accepted-input'), + assistantMessage('duplicate-assistant', 'accepted-input', [toolPart('first', 'Read', 'running', { file_path: 'first.txt' })]), + assistantMessage('duplicate-assistant', 'accepted-input', [toolPart('second', 'Write', 'running', { file_path: 'second.txt' })]), + ]), observedAt); + + assert.deepEqual(events, []); +}); + +test('does not render unallowlisted tool or capability names from a session snapshot', async () => { + const workspace = await mkdtemp(join(tmpdir(), 'zcode-session-progress-')); + const describer = await createSessionProgressDescriber({ + workspace, + turnBoundary: { inputId: 'accepted-input', stateRevision: 7, beforeMessageIds: new Set() }, + }); + const events = await describer.observe(snapshot([ + userMessage('accepted-input'), + assistantMessage('current-assistant', 'accepted-input', [toolPart('private-call', 'PRIVATE_CAPABILITY', 'completed', {}, { startedAt: 10, completedAt: 20 })]), + ]), observedAt); + + assert.deepEqual(events, [{ phase: 'running', message: 'Tool completed (10ms).', observedAt }]); + assert.doesNotMatch(JSON.stringify(events), /PRIVATE_CAPABILITY|private-call/); +}); + +test('uses exactly one visible non-synthetic current user root only when the accepted input root is absent', async () => { + const workspace = await mkdtemp(join(tmpdir(), 'zcode-session-progress-')); + const pathSentinel = 'CONTAINED_SNAPSHOT_PATH_SENTINEL.txt'; + const boundary = { inputId: 'accepted-input', stateRevision: 7, beforeMessageIds: new Set(['old-user']) }; + const describer = await createSessionProgressDescriber({ workspace, turnBoundary: boundary }); + const safe = await describer.observe(snapshot([ + userMessage('old-user'), + userMessage('current-user', { semantics: { origin: 'real_user', kind: 'user_prompt', uiVisibility: 'visible' } }), + assistantMessage('hidden', 'current-user', [toolPart('hidden-call', 'Bash', 'running')], { semantics: { origin: 'agent_runtime', kind: 'assistant_response', uiVisibility: 'hidden' } }), + assistantMessage('sibling', 'different-user', [toolPart('sibling-call', 'Bash', 'running')]), + assistantMessage('linked', 'current-user', [toolPart('safe-call', 'Read', 'running', { file_path: join(workspace, pathSentinel) })]), + ]), observedAt); + assert.deepEqual(safe, [{ phase: 'running', message: 'Running tool: Read.', observedAt }]); + assert.doesNotMatch(JSON.stringify(safe), new RegExp(pathSentinel)); + + const ambiguous = await createSessionProgressDescriber({ workspace, turnBoundary: boundary }); + assert.deepEqual(await ambiguous.observe(snapshot([ + userMessage('first-current'), userMessage('second-current'), + assistantMessage('assistant', 'first-current', [toolPart('call', 'Bash', 'running')]), + ]), observedAt), []); +}); + +test('snapshot source never inspects or serializes tool input paths', async () => { + const source = await readFile(new URL('../scripts/lib/session-progress.mjs', import.meta.url), 'utf8'); + assert.doesNotMatch(source, /state\.input|file_path|formatSnapshotToolStartMessage/); +}); + +test('rejects snapshots older than the accepted revision and emits terminal-first without a synthetic start', async () => { + const workspace = await mkdtemp(join(tmpdir(), 'zcode-session-progress-')); + const describer = await createSessionProgressDescriber({ + workspace, + turnBoundary: { inputId: 'accepted-input', stateRevision: 7, beforeMessageIds: new Set() }, + }); + const messages = [ + userMessage('accepted-input'), + assistantMessage('assistant', 'accepted-input', [toolPart('terminal-call', 'Bash', 'error', { command: 'PRIVATE' }, { error: 'PRIVATE_ERROR', startedAt: 10, completedAt: 15 })]), + ]; + assert.deepEqual(await describer.observe(snapshot(messages, 6), observedAt), []); + assert.deepEqual(await describer.observe(snapshot(messages, 7), observedAt), [ + { phase: 'running', message: 'Bash failed (5ms).', observedAt }, + ]); + assert.deepEqual(await describer.observe(snapshot(messages, 8), observedAt), []); +}); + +test('deduplicates starts and terminals by call id and caps tracked identities at 256', async () => { + const workspace = await mkdtemp(join(tmpdir(), 'zcode-session-progress-')); + const describer = await createSessionProgressDescriber({ + workspace, + turnBoundary: { inputId: 'accepted-input', stateRevision: 7, beforeMessageIds: new Set() }, + }); + const starts = Array.from({ length: 257 }, (_, index) => toolPart(`call-${index}`, 'Bash', 'running', { command: `PRIVATE-${index}` })); + const first = await describer.observe(snapshot([ + userMessage('accepted-input'), assistantMessage('assistant', 'accepted-input', starts), + ]), observedAt); + assert.equal(first.length, 256); + assert.deepEqual(new Set(first.map((event) => event.message)), new Set(['Running tool: Bash.'])); + + const terminals = starts.map((part, index) => toolPart(part.callId, 'Bash', 'completed', {}, { startedAt: index, completedAt: index + 1 })); + const second = await describer.observe(snapshot([ + userMessage('accepted-input'), assistantMessage('assistant', 'accepted-input', terminals), + ], 9), observedAt); + assert.equal(second.length, 256); + assert.deepEqual(await describer.observe(snapshot([ + userMessage('accepted-input'), assistantMessage('assistant', 'accepted-input', terminals), + ], 10), observedAt), []); +}); + +test('ignores an oversized multibyte call id without consuming bounded dedupe capacity', async () => { + const workspace = await mkdtemp(join(tmpdir(), 'zcode-session-progress-')); + const describer = await createSessionProgressDescriber({ + workspace, + turnBoundary: { inputId: 'accepted-input', stateRevision: 7, beforeMessageIds: new Set() }, + }); + const oversizedCallId = '界'.repeat(700_000); + const safeParts = Array.from({ length: 256 }, (_, index) => toolPart(`safe-call-${index}`, 'Bash', 'running')); + const events = await describer.observe(snapshot([ + userMessage('accepted-input'), + assistantMessage('assistant', 'accepted-input', [toolPart(oversizedCallId, 'PRIVATE_TOOL', 'running'), ...safeParts]), + ]), observedAt); + + assert.equal(events.length, 256); + assert.deepEqual(new Set(events.map((event) => event.message)), new Set(['Running tool: Bash.'])); +}); + +test('fails closed on oversized snapshot message relationship ids', async () => { + const workspace = await mkdtemp(join(tmpdir(), 'zcode-session-progress-')); + const oversizedMessageId = '界'.repeat(700_000); + const direct = await createSessionProgressDescriber({ + workspace, + turnBoundary: { inputId: 'accepted-input', stateRevision: 7, beforeMessageIds: new Set() }, + }); + assert.deepEqual(await direct.observe(snapshot([ + userMessage('accepted-input'), + assistantMessage(oversizedMessageId, 'accepted-input', [toolPart('safe-call', 'Bash', 'running')]), + ]), observedAt), []); + + const indirect = await createSessionProgressDescriber({ + workspace, + turnBoundary: { inputId: 'accepted-input', stateRevision: 7, beforeMessageIds: new Set() }, + }); + assert.deepEqual(await indirect.observe(snapshot([ + userMessage(oversizedMessageId), + assistantMessage('assistant', oversizedMessageId, [toolPart('safe-call', 'Bash', 'running')]), + ]), observedAt), []); + + const malformedSibling = await createSessionProgressDescriber({ + workspace, + turnBoundary: { inputId: 'accepted-input', stateRevision: 7, beforeMessageIds: new Set() }, + }); + assert.deepEqual(await malformedSibling.observe(snapshot([ + userMessage('accepted-input'), + assistantMessage('malformed-sibling', oversizedMessageId, []), + assistantMessage('linked-assistant', 'accepted-input', [toolPart('safe-call', 'Bash', 'running')]), + ]), observedAt), []); +}); diff --git a/tests/skills-contracts.test.mjs b/tests/skills-contracts.test.mjs index 0e1f9562..3376e5be 100644 --- a/tests/skills-contracts.test.mjs +++ b/tests/skills-contracts.test.mjs @@ -126,7 +126,7 @@ test('Rescue generic fallback is fixed, fresh, setup-gated, and contains no task assert.match(source, /Act only as the installed ZCode Rescue forwarder\./); assert.match(source, /node "\/scripts\/zcode-companion\.mjs" invoke rescue/); assert.match(source, /Preserve stderr and return public stdout verbatim\./); - assert.match(source, /Do not inspect or modify code independently, interpret results, retry, poll, cancel, choose a pending branch, or request\/print\/persist authorization material\./); + assert.match(source, /Do not inspect or modify code independently, interpret results, retry, cancel, choose a pending branch, or request\/print\/persist authorization material\./); assert.match(source, /never issue a second spawn/i); assert.match(source, /unknown\/unrecognized\/unsupported\/reserved (?:field\/key\/parameter|field, key, or parameter) `agent_type`/i); assert.match(source, /no agent ID, start event, or activity/i); @@ -152,6 +152,27 @@ test('managed Rescue role is a fixed TOML forwarder without capability or task m assert.match(source, /(?:Do not|Never) inspect or modify code independently/i); }); +test('named and generic Rescue forwarders keep yielded executions attached through a real exit code', () => { + const role = readFileSync(new URL('agents/zcode-rescue.toml.template', root), 'utf8'); + const source = skill('rescue'); + const generic = /```text\n(Act only as the installed ZCode Rescue forwarder\.[\s\S]+?)\n```/.exec(source)?.[1]; + assert.ok(generic, 'generic forwarder fixture must be present'); + for (const forwarder of [role, generic]) { + assert.match(forwarder, /result containing an exit code is terminal/i); + assert.match(forwarder, /running execution or session handle is nonterminal/i); + assert.match(forwarder, /poll only that same handle with the host continuation tool until it reports an exit code/i); + assert.match(forwarder, /Partial stdout, stderr, heartbeat text, or an outer code-cell completion is not terminal/i); + assert.match(forwarder, /needs-choice response with exit code 3 is terminal for the current child turn/i); + assert.match(forwarder, /exactly one `exec_command` companion process/i); + assert.match(forwarder, /continuation calls only observe its original running handle/i); + assert.match(forwarder, /(?:do not|never)[^.]*second `exec_command`/i); + assert.match(forwarder, /(?:do not|never)[^.]*retry/i); + assert.match(forwarder, /(?:do not|never)[^.]*cancel/i); + assert.match(forwarder, /(?:do not|never)[^.]*choose/i); + assert.match(forwarder, /(?:do not|never)[^.]*inspect or modify code independently/i); + } +}); + test('native Rescue forwarders request explicit background through the same capability-free constant invocation', () => { const source = skill('rescue'); const role = readFileSync(new URL('agents/zcode-rescue.toml.template', root), 'utf8'); const generic = /```text\n(Act only as the installed ZCode Rescue forwarder\.[\s\S]+?)\n```/.exec(source)?.[1]; @@ -210,3 +231,28 @@ test('contract fixtures are rooted in this checkout', () => { assert.equal(isAbsolute(rootPath), true); assert.equal(existsSync(new URL('package.json', root)), true); }); + +test('opt-in installed Rescue E2E requires real yielded execution and privacy-safe facts', () => { + const source = readFileSync(new URL('tests/e2e/codex-skills-e2e.test.mjs', root), 'utf8'); + const installed = /test\('installed Rescue uses one isolated native child[\s\S]+?\n\}\);/.exec(source)?.[0]; + assert.ok(installed, 'opt-in installed native Rescue test must exist'); + assert.match(installed, /FAKE_ZCODE_COMPLETION_GATE/); + assert.match(installed, /FAKE_ZCODE_COMPLETION_GATE_REACHED/); + assert.match(installed, /FAKE_ZCODE_PROCESS_FILE/); + assert.match(installed, /requireYieldedExecution:\s*true/); + assert.match(installed, /yieldedExecution\.execCommandCount/); + assert.match(installed, /yieldedExecution\.pollCount/); + assert.match(installed, /yieldedExecution\.sameHandleChecked/); + assert.doesNotMatch(installed, /yieldedExecution\.(?:pollHandles|originalHandle)/); + assert.match(installed, /yieldedExecution\.terminalExitCode/); + assert.match(installed, /executions\.initial\.execCommandCount/); + assert.match(installed, /executions\.continuation\.execCommandCount/); + assert.match(installed, /snapshotFallback:\s*'\[zcode\] ZCode conversation frames were unavailable; using bounded session progress\.'/); + assert.match(installed, /lifecycleOnly:\s*'\[zcode\] ZCode semantic progress is unavailable; lifecycle updates will continue\.'/); + const forbiddenBlocks = [...installed.matchAll(/forbiddenParentText:\s*\[([\s\S]*?)\]/g)].map((match) => match[1]); + assert.ok(forbiddenBlocks.length >= 3, 'installed Rescue must check foreground, choice, and background parent isolation'); + for (const block of forbiddenBlocks) { + assert.match(block, /ZCode conversation frames were unavailable; using bounded session progress\./); + assert.match(block, /ZCode semantic progress is unavailable; lifecycle updates will continue\./); + } +}); diff --git a/tests/state.test.mjs b/tests/state.test.mjs index b8f73faf..52fa9519 100644 --- a/tests/state.test.mjs +++ b/tests/state.test.mjs @@ -382,6 +382,7 @@ test('persisted jobs are schema-validated before use', async () => { { ...job, resultArtifact: 'artifacts/result.json' }, { ...job, status: 'running', error: 'too early' }, { ...job, status: 'failed', resultArtifact: 'artifacts/result.json' }, + { ...job, progressProbe: { state: 'online', framesReceived: 1, raw: 'must-fail-closed' } }, ]) { await atomicWriteJson(path, invalidJob); await assert.rejects( @@ -864,6 +865,34 @@ test('progress is a no-op once queued or terminal lifecycle state wins', async ( assert.deepEqual(await store.readJob(workspace, succeeded.id), succeeded); }); +test('persists only the exact bounded progress probe schema for active jobs', async () => { + const { dataRoot, workspace } = await fixture(); const store = createStateStore({ dataRoot }); + const queued = await store.reserveJob({ workspace, ...jobInput }); + const probe = { + state: 'online', subscriptionAcknowledged: true, framesReceived: 255, + acceptedInitial: 1, acceptedOnline: 2, acceptedRecovery: 3, + rejected: { 'wire-version': 1, 'envelope-shape': 2, sequence: 3, topic: 4, 'row-kind': 5, 'row-shape': 6 }, + snapshotFallbackActive: false, snapshotFallbackUnavailable: false, + }; + assert.deepEqual(await store.updateJobProgressProbe(workspace, queued.id, probe), queued); + const running = await store.transitionJob(workspace, queued.id, ['queued'], 'running'); + const updated = await store.updateJobProgressProbe(workspace, queued.id, probe); + assert.deepEqual(updated.progressProbe, probe); + assert.ok(Date.parse(updated.updatedAt) >= Date.parse(running.updatedAt)); + const corrupt = /** @type {any} */ (structuredClone(probe)); corrupt.rejected.secret = 1; + await assert.rejects(store.updateJobProgressProbe(workspace, queued.id, corrupt), { code: 'JOB_PROGRESS_PROBE_INPUT_INVALID' }); + const overflow = structuredClone(probe); overflow.framesReceived = 256; + const saturated = await store.updateJobProgressProbe(workspace, queued.id, overflow); + assert.equal(saturated.progressProbe.framesReceived, 255); + const negative = structuredClone(probe); negative.acceptedInitial = -1; + await assert.rejects(store.updateJobProgressProbe(workspace, queued.id, negative), { code: 'JOB_PROGRESS_PROBE_INPUT_INVALID' }); + await store.transitionJob(workspace, queued.id, ['running'], 'cancelling'); + const cancelling = await store.updateJobProgressProbe(workspace, queued.id, { ...probe, acceptedOnline: 3 }); + assert.equal(cancelling.status, 'cancelling'); assert.equal(cancelling.progressProbe.acceptedOnline, 3); + const terminal = await store.finishJob(workspace, queued.id, ['cancelling'], 'succeeded'); + assert.deepEqual(await store.updateJobProgressProbe(workspace, queued.id, { ...probe, acceptedOnline: 3 }), terminal); +}); + test('future progress is rejected without poisoning a subsequent current update', async () => { const { dataRoot, workspace } = await fixture(); const store = createStateStore({ dataRoot });