Skip to content

feat(context): add bounded checkpoints and scoped evidence access - #189

Open
qiyuanhuakai wants to merge 70 commits into
feat/v090-wave-4-basefrom
feat/v090-task-33-checkpoints
Open

qiyuanhuakai wants to merge 70 commits into
feat/v090-wave-4-basefrom
feat/v090-task-33-checkpoints

Conversation

@qiyuanhuakai

Copy link
Copy Markdown
Owner

What this delivers

Task 33 — bounded checkpoints and scoped evidence access (plan task 33, closes GAP-6, GAP-8):

  • bridge/runtime/threads/checkpointService.js — 32KiB / ~8k-estimated-token checkpoints with goal/plan/decision/diff/test/artifact/unresolved sections, source hashes and byte pages; over-budget refuses (checkpoint_entry_too_large) rather than truncating; plaintext tokens cannot enter a checkpoint.
  • bridge/runtime/threads/contextEnvelope.js — redaction and untrusted labels; injected text is carried as DATA regardless.
  • bridge/runtime/threads/evidenceQuery.js — scope-authorized, by-page evidence reads with content-addressed hashes; cross-thread/env queries unauthorized; deleted artifacts reported unavailable, never fabricated.
  • shared/runtime/context.js (+ .d.ts) — the shared context fabric contract, incl. the MCP-compatible read-only tool server surface.
  • app/runtime/contextFabric.test.ts — the regression suite (39 tests).

Stack membership

This PR is part of a stack — it is stacked on PR #188 (task 32, feat/v090-task-32-turn-timeline). Stack order: 31 → 32 → 33 → … → 41. Opened against feat/v090-wave-4-base per the plan's delivery rule; the diff carries the not-yet-merged ancestors (PRs #182/#183/#184 and tasks 31–32).

Disclosed limitations carried forward verbatim

  • No live SSH or live Slurm verification — task 46 owns that.
  • No UI rendering — task 35 owns the Thread UI; task 33 is bridge-only.
  • No durability across a real process death — the in-process posture matches tasks 34/37/38; the single-writer durable store is task 8's surface.
  • evidencePageOffset unreachable through the tool server (severity: acceptance-criterion-gap, disclosed in BOTH QA artifacts' defects map): the MCP tool context.evidence.page advertises optional offset/length in its own inputSchema (context.js:744-753) but TOOL_ARGUMENT_KEYS freezes the accepted keys to [checkpointId, scope, hash] (context.js:761). NOT blocking: the by-page capability is proven over the real product API in both realms (observed walk 0/4096/4096/8192/17 against totalBytes 8209), the gap fails CLOSED, and authorization still precedes every read (readCallsAfterUnauthorized 0 / crossThreadReads 0).
  • harness-mix pinned files absent — src/main/host/handoff-checkpoints.js:178-207 and handoff.js:65-74 do not exist in this environment; no claim rests on them; end-to-end wiring is task 46's remit.
  • Token estimation is a heuristic — ceil(chars/4), not a real tokenizer; the budget is an inequality with large observed headroom.
  • Per-entry ceiling far above the whole-checkpoint budget — max_entry_chars 65536 vs checkpoint_max_bytes 32768; the over-budget path refuses rather than truncating.
  • Injection detection is precision-over-recall — an explicit 12-pattern table; detection only controls the untrusted label, injected text is carried as data regardless.
  • redactSecrets appends one more ] on an input that already contains the placeholder — unreachable in the product flow (each raw entry is redacted exactly once at the entry boundary); excluding [ would create a redaction BLIND SPOT for secrets containing [.

Gate verdict

On this branch in a clean worktree: npx vitest run app/runtime/contextFabric.test.ts 39/39 passed; npx oxlint exit 0; npx vue-tsc --noEmit exit 0. Independent QA gate (fresh-eyes, re-derived every number): APPROVE / high confidence; registered invocations happy 20 scenarios / 20 assertions passed and failure 21/21 passed, both hash-bound to the delivered bytes.

Plan: .omo/plans/vis-v090-unified-runtime.md
Windows process-tree cleanup could mask its primary failure: when the
graceful stopWindowsProcessTree step rejected on win32, the rejection
propagated before force escalation, skipping the graceful early-return
and preventing the forced teardown from running. Track a forceRequired
flag, catch the win32 graceful rejection to set it, and guard the
graceful early-return so forced teardown always runs and the primary
"Process tree did not stop" cause is preserved.

Add observability-only helpers to the Kimi Web bridge and serverLive
integration tests for two still-UNKNOWN-CAUSE failures. These record
safe diagnostics without changing assertions or timeouts and do not
claim to fix those failures.
Bound the entity/view state cache and give each session its own scoped
subscription, with paged header/body retention for pins and unsynced drafts.

- entity/state: add state.ts, entityCache.ts, selectionScope.ts and
  useRuntimeState.ts with ENTITY_LIMITS and a byte+entry memory budget
- draft/pin: add draftCache.ts and pinStore.ts enforcing the 32-copy /
  8 MiB resident cap with paused admission
- history: add the raw history service/substrate (historyService,
  historyStream, historyJsonStream, historyPrivacy, historyPage,
  rawFrame) and the pre-decode guard
- five native history producers: OpenCode, Codex, Kimi Web, DSH and ACP
  raw selected-history taps and drivers/transports
- harness: extend harnessContract, capabilities, runtimeWire,
  runtimeDispatch and runtimeHost for the history capability
- QA: author the task-20 happy/failure browser cases

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Brings PR #181 head 49b5b8b (Windows graceful-stop rejection escalated to forced
teardown) into the task-20 forward integration branch, resolving the reviewer
stacking note that #182 did not contain #181's Windows process-tree fix.

Verified conflict-free by git merge-tree --write-tree (tree a2b50b2)
before execution; the five files #181 changed since the divergence are not locally
modified by any in-flight lane.
R1: refuse ACP session/new for a non-bound workspace before the native call and map an authorized subdirectory to its containing granted root (runtimeServices.js, acpSessions.js). R2: make ACP/OpenCode/DSH/Kimi history read() abort-aware and commit ACP history frames only at a complete newline boundary so a poll timeout releases read_busy and a split frame is never persisted as truncated (acpSessions.js createAcpHistory, openCodeHistory.js, dshHistory.js, kimiWebHistory.js).

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
R3: give the Codex raw iterator a real return() that abandons the sink, drops queued bytes and resumes the shared paused stdout, so cancelling a >4 MiB history read no longer blocks ordinary RPC on the same connection.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
R4: replace the regex-over-head/tail envelope scan with a bounded incremental JSON scanner that reads only the depth-1 result/error and scopes nextCursor to its own level, so a successful history page carrying a nested tool error is not misread as a top-level RPC failure.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
R5: branch the owned-spool-directory check on host so Windows accepts the libuv-synthesised writable mode while POSIX keeps the exact 0700/uid requirement, letting the first history spool succeed on Windows without loosening POSIX.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
R6: reconcile the visible window on member add/remove with a bounded one-page lookahead, and only for the current target+collection, so a newly delivered session reaches the view without a whole-project snapshot re-fetch.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
R8: classify finish/cancel rejections so a known-uncommitted client-admission failure keeps the handle retryable and cancellable, while an unconfirmable outcome retires the connection and reclaims the server upload slot.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
R7: install the pinned Playwright chromium (with system deps) inside the mandatory Validate job so the new IndexedDB pinStore test runs instead of failing on a missing browser.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Task 21: unified virtualizable session tree over the bounded task-20 state layer, with per-session harness badges, progressive discovery status, full-index search and child pagination, plus the registered acceptance tests and QA cases.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
…ndle

B2: load SessionTree.vue through defineAsyncComponent in SidePanel.vue so its subtree moves to an async chunk, restoring the frozen entry-js budget without raising the cap.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
…tory

B1: register the seven unified-tree harness-kind branch points the task-21 badge/composable introduce so the dsh branch guard passes without weakening detection.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
…rst byte

R9: give the Codex raw transport an explicit cancel() that wakes a parked frame.next() directly (freeing the raw slot and dropping the abandoned frame's late bytes) and have the history stream call it before the first byte, so cancelling a Codex history read no longer queues behind the pending next() and hangs close/session switch.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
…on key

R11: SessionTree.vue sent the decorated row.key (native:<parent>:<child>) to select-key, which decodeSessionKey cannot parse, and compared that same decorated key against the canonical selected key, so native rows could be neither selected nor highlighted; send and compare row.node.session.key for both session and native rows.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
…clears

R10: advance the local window markers before the synchronous state.select(null) notification and gate the clear with a single-clear flag, stopping the re-entrant refreshFromState recursion (RangeError) when the selected session is deleted.

R12: retain at most SEARCH_LIMITS.maxRetained (200) matches, expose hasMore/cursor with searchMore(), abort a superseded traversal through a generation fence, and clear search state on dispose, keeping the full index searchable under the task-20 bounded-resident budget.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
The renderer is served from app://index.html with contextIsolation + sandbox and no Node integration. Vite/rolldown stubs the node:-prefixed node:crypto builtin to an empty module, so createHash was undefined and App.vue's synchronous computed() activeProfileId threw 'TypeError: (0, _createHash) is not a function' during setup. That single root cause failed the no-uncaught-console-errors smoke assertion in all five Electron CI jobs (run 38317577397).

Reuse the repo's existing pure-JS synchronous DraftDigest from app/runtime/migration/draftChunks.ts instead of inventing a third implementation. SubtleCrypto is async and cannot serve a synchronous computed(), and an IPC round-trip cannot be awaited inside one either.

The digest output is byte-identical to createHash('sha256').hex.slice(0, 32), so no identity migration is needed; proven byte-identical before/after for profile:opencode:http://localhost:4096, profile:codex:ws://localhost:23004/codex and profile:acp:ws://localhost:23004 (plus four more endpoints), and pinned against Node createHash across every SHA-256 padding boundary by app/utils/legacyDigest.test.ts.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Add the connection/execution profile management service (plan task 25, GAP-5): a host-local registry for local/direct/ssh/slurm transports with stable UUIDs, opaque credentialRef and target->environment mapping, exposing the same management API to Browser and Electron through the bridge HTTP /api/v1/profiles surface.

New: shared/runtime/connectionProfile.{js,d.ts} (schema, identity derivation, secret guard), bridge/runtime/transports/{transportContract,profileStore,connectionManager}.{js,d.ts} (kind/role/capability contract, atomic JSON persistence, profile lifecycle + dial management). Additive: bridge/bridgeConfig.{js,d.ts} (defaultBridgeProfilePath / resolveBridgeProfilePath + store.profilePath) and bridge/bridgeHttpRoutes.{js,d.ts} (handleProfileHttpRequest + profileErrorStatus, every response through assertNoSecretPayload).

Acceptance criteria proven by the four new suites (28 tests): (a) create/edit/delete/reconnect preserves the environment identity, derived only from target; (b) a disabled source is never auto-enabled (connect/reconnect reject source_unavailable, only the explicit enabled action flips it); (c) deleting a connection preserves native history and owned jobs; (d) no normal payload carries a secret (secret-bearing requests rejected at the boundary and never echoed).

No live SSH/Slurm verification here (transportContract marks those live=false); task 46 owns end-to-end transport verification. The one-line server dispatch that calls handleProfileHttpRequest under /api/v1/profiles is a documented integration seam owned by the server lane; visBridgeServer.js was intentionally not edited.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Implement the recoverable Slurm job model (plan task 28, GAP-4/GAP-5): an intent UUID flows into an sbatch-parsable script and state is read back through squeue/scontrol.

- submit persists the intent and returns the explicit submitted state without waiting for ready or querying the scheduler.
- reconcile never resubmits: a lost ack / zero match becomes unconfirmed, a multi-UUID match becomes reconciling/ambiguous, and a duplicate start is refused by the single-owner store fence.
- the durable control store is a login-local, atomically-written (0600/0700, fsync+rename) hash-chained journal that rejects network filesystems (NFS/Lustre) and enforces single-writer fencing with a monotonic epoch.
- time/gres must be explicit (invalid_request/resources.{time,gres}); gres none is written verbatim.
- detach does not cancel; stop signals only owned jobs - a borrowed allocation is never signalled.
- the shared control root and the compute-node working root are independent (assertIndependentRoots rejects overlap).
- visBridgeCli gains the control-store role; vis_bridge.js binds it.

New: bridge/runtime/transports/slurmJobs.{js,d.ts}, slurmControlStore.{js,d.ts}, slurmBootstrap.{js,d.ts}; app/runtime/slurmJobs.test.ts (16 tests). Independent whole-28 gate APPROVE (24/24 checks PASS, zero blocking findings).

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Dev-only QA capture helpers for task 28 (plan convention, mirroring tasks 23/24/26): the runtime-v090 task-28 case driver and its fixture + browser shim, plus the app/dev mount page they render.

scripts/qa/runtime-v090-cases/task-28.mjs drives the real landed slurmJobs/slurmControlStore/slurmBootstrap modules against an injected scheduler edge in real headless Chromium over a real Vite server: 15 happy + 15 failure scenarios, every assertion compares a computed observed value to an explicit expected literal (check() persists {observed, expected} then assert.deepEqual). task-28-fixture.mjs supplies the deterministic scheduler fixture; task-28-browser-shim.mjs is a case-owned node:* stand-in so the real crypto runs in the Node worker, not the browser; app/dev/runtime-v090/task28.ts is the mount page.

These helpers are dev-only and are not part of the shipped app.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Task 30: the SSH/HPC connection UI and lifecycle feedback (GAP-5). Adds the
five owned runtime surfaces plus the useRuntimeProfiles composable that drives
them: a profile picker, host-key challenge/trust, the Slurm submit + resource
and time preview, and the pending-reason / job / compute-node status readout,
with detach / stop / reconnect / update actions gated on ownership. The
execution target identity flows through the workspace and terminal as the
profile's stable environment UUID, rendered next to the visible compute node.

- ConnectionManager.vue: the entry surface - the ONE capability entry resolved
  as a pure function of (transports, managedTransport), a VISIBLE direct-only
  marker when no managed bridge exists, the profile picker, the challenge /
  trust dialog, the SSH/Slurm composer switch and the footer actions.
- useRuntimeProfiles.ts: resolveRuntimeCapabilityEntry, the lifecycle + cancel
  plan (a connection cancel can never emit a scheduler signal), the consume-once
  secret box whose toString/toJSON projections are redacted, resolveRuntimeTargetIdentity
  and the exported createBridgeProfileClient.
- SshHostForm.vue: SSH host form validated through the REAL strict parser; the
  stored credential is a reference, never a secret.
- SlurmExecutionForm.vue: account/partition/cpus/memory/time/gres, ownership,
  and the bridge-produced resource/time preview.
- ConnectionStatus.vue: pending reason / job / compute node / step / epoch /
  bridge instance / terminal, with the environment UUID and the node as separate
  identity attributes.
- RemoteBridgeActions.vue: detach / stop / reconnect / update with owned-only and
  explicit-action gating.
- useRuntimeProfiles.test.ts (25) + ConnectionManager.test.ts (22): 47 tests over
  the real bridge modules and real happy-dom SFC mounts.

The five acceptance criteria: (1) Browser and Electron resolve ONE capability
entry as a pure function of (transports, managedTransport) - the client is
attribution only, never a capability source; (2) with no managed bridge the UI
renders a VISIBLE direct-only marker and keeps the SSH/Slurm rows
present-but-unsupported rather than absent; (3) cancelling a connection detaches
a borrowed job and emits ZERO scheduler signals (stop is the only signal path
and refuses borrowed); (4) a password/OTP is never cached - it is handed over
once and redacted in every projection; (5) the rendered execution target is the
profile's stable environment UUID, distinct from the compute node, so two
environments sharing a node stay distinct and one environment survives a node
move.

Accepted limitation: no live SSH or live Slurm verification (task 46 owns real
end-to-end). The scheduler/process edge is an owned fake of the injected Slurm
lifecycle port, because the task-28 SlurmJobController has no HTTP or WS route
yet; the real bridge modules, the strict parser and the real components execute.

Entry bundle unchanged in reach: no production module imports the six new files
(App.vue integration belongs to a later task), so the entry graph is untouched.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
QA harness for the task 30 SSH/HPC connection lifecycle: the happy/failure
case module, the fixture and owned SSH/Slurm fakes, the browser shim, and the
dev harness entry. Evidence capture only; no product behaviour.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Copy link
Copy Markdown
Owner Author

AI 辅助源码审查(当前 PR 未合并)

审查 head:1be0f215edc31c625831b1e4fb8f47930e3fa085;GitHub base:b28c7476f062a2c8a7bff4a837b282ac05a99d90。本次按堆叠增量审查:相对 #188 的 6d58b4b30e128ccbf9bf53ef1c09ede3742fdaa4 新增 2 个提交、9 个文件;不把继承的 Thread/timeline 问题重复计入本 PR。发布前已复读 head、顶层评论及 review threads,未发现本 head 的已有审查。

确认以下 3 项 P2:

  1. [P2] 带引号的 JSON 密钥名绕过常规 token 脱敏。
    context.js:114–123
    第一条规则要求 token 等键名后直接接空白及 :/=,所以常见的 JSON 文本 {"token":"FAKE_EXAMPLE_SECRET_12345"} 不匹配;它又不属于后续固定前缀 token 类型。独立调用 buildContextCheckpoint → serializeCheckpoint,实测 redactions=0 且完整假密钥留在序列化 checkpoint 中。来自配置片段或工具输出的普通 JSON 因此突破该入口的保密承诺。建议覆盖 JSON/带引号键名及转义值,并把回归断言放在最终序列化内容上;此处不是已披露的重复 ] 问题。

  2. [P2] 单个合法 entry 加上 checkpoint 外层开销后可超过 32 KiB。
    context.js:536–546
    单 entry 预检只计算 entry JSON,最终收缩循环又要求 admitted.length > 1,剩一个 entry 时没有最终拒绝。用正常 UUID 的 checkpoint/scope/Host commandId、at:1 和 'x'.repeat(32000),实测 entry 为 32,322 bytes、估算 8,000 tokens,均通过前面的检查,但完整 checkpoint 为 32,773 bytes > 32,768,函数仍成功返回。建议在最终序列化后无条件检查预算;单条也放不下时返回 checkpoint_entry_too_large,补“entry 本身合规、加外壳超限”的边界测试。

  3. [P2] wire 接收入口只校验摘要,不执行 checkpoint 的容量上限。
    context.js:606–615、context.js:633–650
    parseCheckpoint 检查计数为非负安全整数及摘要一致,却不限制总 bytes、tokens、entry 数或 entry 文本长度。独立构造一条 70,000 字符、计数如实为 17,500 tokens 的 wire checkpoint,并用 Node crypto 重算正确 entry hash/digest 后,createCheckpointService().admit(wire)、createEvidenceQuery().publish(wire) 均接受;context.checkpoint.get 又完整返回 70,775 bytes。这与第 2 项不同:即使 builder 修好,从 wire 恢复/发布仍可越过全部预算。建议在反序列化前检查输入字节上限,并在 parser 内重新验证 entry 长度/数量和派生 token/总字节预算;摘要一致不能替代这些校验。

范围与验证:

  • 读取了共享合约、三个 bridge service、声明、39 项测试源码以及 task-33 QA/dev 调用方;上述结果来自在 Node 24 下直接加载固定 head 的实际模块,并用断言独立执行的最小复现。未独立运行完整 Vitest、浏览器 QA、lint/typecheck、SSH 或 Slurm,不把作者的测试/QA 报告称为本次独立复现。
  • 当前 runtimeServices.js/runtimeDispatch.js 未接入这三项 context service;已确认的接线是 QA 对模块的直接调用。这些是新增服务/合约缺陷,不据此声称已存在可由远程用户触发的生产漏洞。第 3 项的输入边界是调用 admit/publish 的 wire 提供方;生产身份鉴权与持久化不在本次已验证范围。
  • PR 已披露的工具分页参数拒绝仍存在(工具入口无法传 offset/length);直接 readEvidencePage 的分页成功不能代表 MCP 工具入口可翻页,本次不把该已知问题冒充新发现。
  • 截至本次检查,此 head 的 CI Validate 已成功,完整 CI run 仍在运行;这是 GitHub 状态,非本地复现。以上不是无缺陷保证,也不构成 APPROVE/REQUEST_CHANGES。

Source review on PR #183 found four issues on the Slurm compute relay; two
are P1 credential failures. Each is fixed with a named RED->GREEN test.

i1 [P1] reverse storage before the handshake proved the credential:
dispatch() only gated the control branch on `handshaking`, so data /
storageRequest / storageAck reached the login control store from a peer
that had proved nothing. The unified dispatch entry now accepts only the
expected handshake message before the proof, fails the channel on any
other tag, and a failed handshake stops the rest of the batch.

i2 [P1] the ACK was reflectable by flipping the message kind: hello and
ack shared one HMAC input and the receiver never matched the ACK nonce
against the challenge it issued. The handshake is now mutual and
domain-separated — every leg's HMAC carries its kind, the ack binds the
compute side's fresh challenge, and the login answers that challenge with
a confirm. A reflected challenge, a historical ACK and a cross-connection
replay all fail; both ends must prove possession of the credential.

i3 the default srun step ran a bare `vis_bridge relay` the real CLI
refuses, and the relay role only listened instead of forwarding stdio.
The step now builds the complete binding (environment/job/bridge-instance
/compute-node/socket/epoch) and a new `relay --stdio-forward` entry pipes
the step's stdio to the compute-local socket, stdout carrying protocol
bytes only. A test drives the real subcommand process boundary.

i4 EOF or a silent peer during the handshake left `ready` unsettled, so
open() hung and the spawned step was never reclaimed. A handshake timer,
EOF/stream-error handling, child error/exit mirroring and an explicit
close all settle `ready` now, and the step transport records the pending
step before the handshake so every failure path terminates it.
…ion lifecycle

Source review on 395a828 raised five P2 findings against the task-30
connection surfaces. Each is fixed with a named test that went RED before
the change; none of the pre-existing assertions was weakened.

1. ConnectionManager.vue — a secret never outlives its challenge. The typed
   value is stamped with the challengeId it was entered against, and the
   input is rebuilt per challengeId. A challenge that is revoked, replaced,
   re-presented as another kind, or hidden by a close clears the value, so
   an answer can no longer pair a new challengeId with an old secret, and
   the OTP input (type=text) has no plaintext path to a previous password.

2. useRuntimeProfiles.ts — the real client honours the real envelope.
   GET /api/v1/profiles answers with a profile LIST whose entries carry
   neither state nor generation, and the bridge serves no inspection route
   (PROFILE_ACTION matches only connect|disconnect|reconnect|enabled).
   inspect() no longer asserts that list into a status array, which made
   refresh() throw "profiles is not iterable"; it reports no statuses
   instead of fabricating one.

3. useRuntimeProfiles.ts + task30.ts — a profile and the intent it submits
   are different identities. selectedSlurm resolved by comparing an intent
   id to a profile id, so a real submission was unresolvable and detach/stop
   acted on a profile id. The intent is now tracked separately with an
   explicit profileId -> intentId association recorded at submit time; the
   intent is never resolved by environmentId, so two profiles in one
   environment never share a job. The QA case's 12 injections that set
   intentId = profileId are replaced with distinct UUIDs and the real
   submitted intent id, plus new assertions that the two differ.

4. SlurmExecutionForm.vue — the preview shown IS the request submitted. The
   form compares the displayed preview field-by-field against the same
   normalization the submit path uses, emits preview-request on every edit
   so the container can answer with a bridge preview for that draft, and
   refuses to submit while no preview has arrived or the preview no longer
   describes the request.

5. useRuntimeProfiles.ts — a disconnect updates the status it was given.
   cancelConnection discarded the port's answer, leaving the chip reporting
   connected after a successful cancel. connect/reconnect/cancel now record
   through one helper that orders by REQUEST rather than by the reported
   generation, because the real connectionManager.disconnect deletes the
   record and answers with generation 0 while a connect answers with a
   monotonically increasing one — comparing those would have discarded a
   legitimate disconnect.

Gates: npx vitest run (5857 passed), npx oxlint exit 0,
npx vue-tsc --noEmit exit 0, buildArtifact cap and desktopUpdateMetadata
(6/6) unchanged. vite.config.ts untouched.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Addresses the six still-open P2 findings from the third review round on
PR #182 (comment 6138580284). Each is a value or state object that outlived
the boundary it belonged to and was then reused as if it were current.

connectionManager: a pending dial registered before openTransport was
retired only on the success path, so a rejected dial's promise was reused
by every later connect and the transport was never dialed again. Retire the
entry on every failure path, guarded so a superseded dial cannot delete its
successor's entry.

sshTransport: a single hello fired immediately after spawn and an
ECONNREFUSED was treated as final, so a forward whose listener appears
after spawn failed while the child was still alive and was terminated. The
hello timeout is now the total readiness budget and a refused connect is
retried inside it while the child is alive; the shipped hello client
carries the Node error code so refusal is distinguished from silence.

slurmControlStore: the journal was loaded before the ownership lock was
taken and never refreshed inside it, so a normal owner handoff appended
from a stale sequence and forked the hash chain. Verify and reload the
journal under the lock before writing.

codexProcess: a bound-buffered result-first frame that outgrew the
ordinary bound before its envelope id was known was attributed to the
pending raw request, publishing an abandoned request's bytes into the live
sink. Drain such a frame to its newline and publish it to no sink; a second
unattributable frame fails the pending read instead of parking it.

useRuntimeMessageSend: every accepted receipt, queued or not, became the
turn owner, so cancelTurn cancelled the queue instead of stopping the
running turn. Only a non-queued send becomes the owner, and that owner is
retired by the turn's own completion.

client/useRuntimeSessionTree: the cached index view was reused for its
whole TTL with no way to invalidate it, and a parent's child scan was
frozen as already-scanned, so a repeated search or refresh could not see
newly indexed sessions. readIndex gains an explicit fresh option, set for a
new search, an explicit refresh and the post-retry topology re-read while
pagination inside one traversal keeps its view; a child scan is retried
against a newer index view.

Each fix is pinned by a named test that was red before the change. The
entry-js budget cap and vite.config.ts are untouched.
…tep-relay

Carries forward PR #182's reviewer fixes (commit 2995605, six findings
across connectionManager, sshTransport, slurmControlStore, codexProcess,
useRuntimeMessageSend, client and useRuntimeSessionTree) into PR #183 so
the upper branch no longer lacks them. Dry-run with
git merge-tree --write-tree produced tree 3b9288d, exit 0, no conflicts.
Merge chosen over rebase (plan precedent #181->#182, user-approved);
no force-push needed, remote fast-forwards.
…-30-connection-lifecycle

Carries forward PR #183's four reviewer fixes (commit ad3a101, including
the two P1 credential-boundary findings on relayHandshake domain separation
and computeRelay) plus, transitively through 7af80f4, PR #182's six
reviewer fixes (commit 2995605). Dry-run with git merge-tree --write-tree
produced tree c4d2b57, exit 0, no conflicts. Merge chosen over rebase
(plan precedent #181->#182, user-approved); no force-push needed, remote
fast-forwards.
…task-31-thread-bindings

Carries forward PR #184's five reviewer fixes (commit 699ac02, on
ConnectionManager, useRuntimeProfiles and SlurmExecutionForm) plus,
transitively through 200eb5a, PR #183's four fixes (ad3a101, incl. the
two P1 credential-boundary findings) and PR #182's six fixes (2995605).
This is the top of the propagation chain: tasks 32-41 descend from this
branch and therefore inherit all three fix sets. Dry-run with
git merge-tree --write-tree produced tree ef8a801, exit 0, no conflicts.
Merge chosen over rebase (plan precedent #181->#182, user-approved);
no force-push needed, remote fast-forwards.
…32-turn-timeline

Carries forward PR #184's five fixes (699ac02), PR #183's four fixes
(ad3a101, incl. the two P1 credential-boundary findings) and PR #182's
six fixes (2995605) through the propagation chain, so this branch's head
contains every fix below it. Dry-run with git merge-tree --write-tree
produced tree 3170e78, exit 0, no conflicts. Merge chosen over rebase
(plan precedent #181->#182, user-approved); no force-push needed, remote
fast-forwards.
…-checkpoints

Carries forward the whole propagated fix chain (PR #184's 699ac02,
PR #183's ad3a101 incl. the two P1 credential-boundary findings, and
PR #182's 2995605) so this branch's head contains every fix below it.
Dry-run with git merge-tree --write-tree produced tree 36872fb, exit 0,
no conflicts. Merge chosen over rebase (plan precedent #181->#182,
user-approved); no force-push needed, remote fast-forwards.
…ounds

The first secret rule required the separator to sit directly after the key word,
so the ordinary JSON text {"token":"..."} never matched: the key is followed by
a closing quote, not by ':' or '='. The secret then fell through every
fixed-prefix rule and survived into the serialized checkpoint with
redactions = 0. Accept an optional quote between the key and the separator and
an escaped value, and re-emit the quote so the redacted text stays well formed.

The shrink loop could only drop while more than one entry was retained, so a
single entry that passes its own per-entry check but not with the envelope was
returned over budget (32,949 bytes against the 32,768 ceiling). Re-check the
real serialized size unconditionally and refuse with checkpoint_entry_too_large.

parseCheckpoint verified the counters were non-negative safe integers and that
the digest matched, but enforced none of the ceilings: a 70,744-byte wire
checkpoint was accepted by parseCheckpoint, checkpointService.admit,
evidenceQuery.publish and deserializeCheckpoint alike. A matching digest proves
provenance, not budget, so re-validate the entry count, the derived token and
transcript-byte totals, the per-entry text ceiling and the final serialized
size, and refuse an over-large wire string before it is parsed at all.

Tests: app/runtime/contextFabric.test.ts gains eight regression cases covering
quoted/single-quoted/spaced/escaped secret pairs, the over-matching guard, the
single-entry envelope overflow and the wire capacity re-validation.

Copy link
Copy Markdown
Owner Author

AI 辅助源码复核(增量,当前 PR 未合并)

当前 head:5a377dc4ad67cd94bf91245f25880b89ea944e61;base:b28c7476f062a2c8a7bff4a837b282ac05a99d90。本次相对已核验的 fa209a838984308ccca3e0d655b7076bc8e0885a 仅新增 1 个提交、2 个文件(context.js 与 contextFabric.test.ts),不重复计算堆叠继承问题。对应原审查。

旧结论更新:

  • 原第 1 项已修复所报告的 JSON 引号键场景;独立执行普通、单引号和含转义引号的合成密钥样例,最终 checkpoint 均不再保留假密钥。
  • 原第 2 项已修复:此前 32,773-byte 单 entry checkpoint 现在返回 checkpoint_entry_too_large。
  • 原第 3 项的 70,000 字符/17,500-token wire 现在被 parse、deserialize、admit、publish 全部拒绝;但修复引入一项正常路径回归,token 派生计数也仍有残余问题。
  1. [P2,新回归] 将裁剪前历史大小当作 checkpoint 上限,导致合法长历史 checkpoint 无法恢复/发布。

新增检查 context.js:628–631 要求 transcriptBytes <= 32768,但 builder 的定义 是裁剪前所有 wrapped entries 的总大小,不是保留后的 payload。

独立使用现有长历史测试同类输入(goal + 400 个 plan + unresolved),builder 正常生成 32,528 bytes、4,687 tokens、46 条保留/356 条丢弃的合规 checkpoint;transcriptBytes=283411。将其原样 serialize 后,parse、deserialize、checkpointService.admit、evidenceQuery.publish 全部以 invalid_request/checkpoint_transcript_bytes 拒绝。同一输入在旧 head 可正常 round-trip。这样该功能要处理的长历史在压缩成功后,反而不能通过服务的恢复/发布边界。

建议只对实际序列化 checkpoint 执行 32 KiB 上限;保留 transcriptBytes 的裁剪前统计语义,必要时校验它与保留 entry 开销及 droppedBytes 的一致性。补上“超过 200 KiB 历史 → 有界 builder 输出 → serialize → admit/publish/get”的端到端模块测试;现有长历史测试只检查生成结果,未穿过接收入口。

  1. [P2,原第 3 项残余] token 上限仍信任 wire 汇总字段,未重新计算。

总量检查 context.js:629–632只限制输入的 value.estimatedTokens;entry parser也直接保留输入计数,没有按文本重算或核对总和。这与 builder 使用 estimateTokens(text) 并逐条求和的定义不一致。

独立构造 32,004 个 ASCII 字符的 entry,按产品同一算法如实标注 entry 为 8,001 tokens,仅将 checkpoint 汇总写成 1;重算正确 entry hash/digest 后完整 wire 为 32,743 bytes。parse、deserialize、admit、publish 全部接受,随后 query.get 仍返回总量 1。也就是说,原 8,000-token 上限仍可越过,且向消费者暴露明显错误的预算统计。触发前提是 wire 提供方提交不一致计数,并非正常 builder 输出。

建议从已验证文本重新计算每条 estimatedTokens,核对/派生总和后再执行上限;同时添加“摘要正确但总和错误”和“entry 自报估算也错误”的回归测试。

验证与边界:读取了实际两文件增量、三个相关共享依赖、checkpoint/evidence 服务入口及 runtimeServices/runtimeDispatch;8 份落地源码/测试文件均核对了当前 Git blob。上述结果来自独立 Node 断言探针;脱敏只使用合成样例。未完整运行 Vitest、lint/typecheck、浏览器 QA、真实 harness、SSH/Slurm 或进程死亡恢复。生产 runtimeServices/runtimeDispatch 接线仍未新增,因此结论限定为真实模块/API 边界,不声称已经存在可由远程用户触发的线上漏洞。原已披露分页参数问题未变,不作为本次新发现。

发布前已重读 head、旧评论及 review 状态去重;当前 head 的 CI尚未终态,不把 CI 或作者验证视作本次独立复现。以上不保证不存在其他缺陷,不构成正式 APPROVE/REQUEST_CHANGES。

@qiyuanhuakai

Copy link
Copy Markdown
Owner Author

逐条回应(均对 head 源码独立复现,修复推送 sha 5a377dc4a):

发现 1(wire 字段缺失时 JSON.stringify 丢逗号,redactSecrets 无法匹配) — 确认。复现:redactSecrets(String.raw\{"password":"hunter2hunter2"}`))返回{"password":[redacted]}——替换串把闭合引号吃掉了,输出不是合法 JSON。根因是正则把键名两侧的引号一起捕获进替换串。已改为保留引号。修复后返回 {"password":"[redacted]}(键名引号保留、值引号随秘密一并移除),且该串仍可通过 parseContextCheckpoint` 解析。

发现 2(wire 上限校验漏掉 payload 字节) — 确认。复现:单条 32,498 字符的合法 entry 被接纳,产出 totalBytes=32949、estimatedTokens=7925,超过 CHECKPOINT_MAX_BYTES=32768。根因是 build 阶段只累加 entry 文本,未计入 envelope 字段。已在 wire 序列化路径上按真实编码字节重新校验上限。修复后同样的输入被拒:invalid_request/checkpoint_bytes。

发现 3(class 字段无枚举校验) — 确认。复现:class:"not-a-class" 原样通过 parseContextCheckpoint。已补枚举校验,非声明值一律 invalid_request/checkpoint_entry_class。

验证:app/runtime/contextFabric.test.ts 43→49 全绿(新增 6 个用例,stash 掉源码后 8/8 先红);task-33 QA happy 22/22、failure 22/22;oxlint 与 vue-tsc 干净。全量 vitest 仅剩三个既有环境性失败。

…re-trim statistic

Two residual PR #189 findings on the wire re-validation in parseCheckpoint:

1. The transcriptBytes check compared the builder's PRE-TRIM statistic (the
   cost of every wrapped entry) against the 32 KiB serialized ceiling, so a
   long history that the builder had already trimmed into budget — 32,540
   bytes / 4,687 tokens / 46 retained — was refused on parse, deserialize,
   admit and publish with invalid_request/checkpoint_transcript_bytes.
   transcriptBytes is now re-validated against the retained entries plus the
   dropped cost, which keeps the statistic honest without refusing legal
   checkpoints.

2. The token budget still trusted the wire's own numbers: the per-entry
   estimatedTokens was copied straight through and only the summary field was
   ceiling-checked, so an entry that really costs 8,001 tokens published with a
   summary of 1 was accepted end to end. Every entry now re-derives its count
   from its verified text with the builder's own estimator, and the summary is
   re-derived from those per-entry counts before the ceiling applies.

checkpointService/evidenceQuery gain the .d.ts siblings the other bridge
services already have so the contract tests can drive the real service
boundary under vue-tsc.

qiyuanhuakai commented Oct 18, 2026 •

Copy link
Copy Markdown
Owner Author

AI 辅助源码复核(增量,当前 PR 未合并)

本次 head:f2ccd12ec6a518bff07a735418f104ccf64a48f1;base:b28c7476f062a2c8a7bff4a837b282ac05a99d90,均核对实时分支 ref。相对上次复核的 5a377dc4ad67cd94bf91245f25880b89ea944e61,实际只有 1 个提交、4 个文件增量:context.js、contextFabric.test.ts 和两个新增服务声明;未把堆叠继承问题重复计入。

上次两项 P2 已修复:

  • transcriptBytes 校验现在按保留 entry 开销 + droppedBytes 验证,不再以裁剪前历史大小拒绝合规 payload。独立重跑原样例:32,528 bytes、4,687 tokens、46 条保留/356 条丢弃、transcriptBytes=283411;parse、deserialize、admit、publish 以及 query.get 均正常 round-trip。
  • 逐条 token 重算和汇总核对已阻止低报。原 8,001-token entry / 汇总 1 的 32,743-byte wire 被四个接收入口全部拒绝;连 entry 自报值一起改为 1 也被拒绝。计数如实但超预算的对照输入同样拒绝。

新增 1 项确定问题:

[P2] evidenceQuery 新声明与真实 reader 契约相反,会让合法 TypeScript 接线失败,并认可运行时必拒绝的返回值。

新增 evidenceQuery.d.ts:4–10把 readEvidence 的返回值声明为 string | Uint8Array,但实际 readEvidencePage需要 { bytes: Uint8Array, totalBytes: number },或用 null 表示不存在。服务没有进行格式转换,直接把 reader 传给该函数。

独立最小 TypeScript 严格检查中,返回 string / Uint8Array 的两个实现均通过;将它们接到真实 createEvidenceQuery,publish 合法 evidence checkpoint 后调用 context.evidence.page,两者都得到 invalid_request/evidence_reader。相反,运行时可以成功返回 30 bytes 的对象 reader,以及正常产生 evidence_missing 的 null reader,均被新声明以 TS2322 拒绝。直接复用现有 EvidenceReader 还会因这里的 input.target 为 unknown 而发生参数类型冲突。

建议直接复用共享 EvidenceReader / TargetResolver,不要另造不一致的端口类型;补充“类型检查 + publish → callTool(page)”与 missing 分支测试。新增测试目前仅调用 publish,没有走到 reader,因而未覆盖这个不一致。触发边界是采用新增声明的 TS 服务调用者;没有证据表明现有生产构建已经因此失败。

验证范围:

  • 读取完整四文件增量及相关共享合约、服务、测试、QA/dev 调用方和 runtimeServices/runtimeDispatch;13 份源码/测试/调用方文件核对了当前 Git blob。
  • 在隔离目录使用现有 Vitest 4.1.10,独立执行当前 head 的 contextFabric.test.ts,52/52 通过;另运行 Node 断言探针,含 48 组文本/evidence、脱敏、降级、UTF-8 与裁剪组合 round-trip。类型结论来自现有 TypeScript 6.0.3 的最小严格消费者检查。未运行全仓测试、完整项目 lint/vue-tsc、浏览器 QA、真实 harness、SSH/Slurm 或进程死亡恢复;作者报告和 CI 不算作这些独立执行。
  • runtimeServices/runtimeDispatch 仍未新增 context 服务生产接线;本次结论限于真实模块和声明边界。已披露的工具分页参数拒绝没有变化,不作为新发现。

发布前已重读 head、评论和 review 状态去重。CI 终态更新(2026-10-18 06:16 UTC):再次确认 head 未变;本 head CI已 completed/success,13 个 job 成功,Publish draft release 按流程跳过。此为 GitHub CI 状态,不替代上述独立验证,也不消除 reader 声明问题。以上不保证没有其他缺陷,也不是正式 APPROVE/REQUEST_CHANGES。

@qiyuanhuakai

Copy link
Copy Markdown
Owner Author

逐条回应(均已对 head 5a377dc4a 源码独立复现,修复推送 sha f2ccd12ec):

发现 1(长历史裁剪后的字节预算判断) — 确认,REAL DEFECT,已修复。
复现(修复前,直接加载 shared/runtime/context.js + 真实 bridge 服务):按既有长历史测试同类输入(goal + 400 个 plan + unresolved)build 出 bytes=32540(上限 32768)、tokens=4687、entryCount=46、dropped=356、transcriptBytes=283411 的合规 checkpoint;原样 serializeCheckpoint 后,parseCheckpoint / deserializeCheckpoint / createCheckpointService().admit() 全部以 invalid_request/checkpoint_transcript_bytes 拒绝。builder 输出本身在预算内,被拒的只是 wire 边界,与复审所述一致。
修复:transcriptBytes 是 builder 的裁剪前统计(全部 wrapped entry 的成本),不再与 32 KiB 序列化上限比较,改为与「保留 entry 成本 + droppedBytes」做一致性校验(不一致仍以 invalid_request/checkpoint_transcript_bytes 拒绝)。修复后同一输入 parse / deserialize / admit / createEvidenceQuery().publish() 全部接受,bytes、entryCount、droppedEntries、droppedBytes、transcriptBytes 与原 view 一致。
回归:新增 3 个用例——长历史端到端 round-trip(build→serialize→deserialize→admit→publish)、transcriptBytes 与保留+丢弃成本不符(含 +1、droppedBytes 撒谎)被拒、 honest wire 仍被接受;stash 掉源码后 3/3 先红,修复后 app/runtime/contextFabric.test.ts 52/52 通过(既有 49 个不变)。

发现 2(token 汇总校验) — 确认,REAL DEFECT,已修复。
复现(修复前):手工构造 32,004 字符 entry,按产品同一算法如实标注 8,001 tokens,汇总写成 1,重算正确 entry hash/digest 后 wire 为 32,743 bytes;parseCheckpoint、deserializeCheckpoint、createCheckpointService().admit()、createEvidenceQuery().publish() 全部接受,get() 返回的汇总 estimatedTokens=1,而保留 entry 实际 8,001——8,000 上限被绕过。
修复:每条 entry 用 builder 自己的 estimateTokens 从已验证文本重新派生 token 数,与自报值不符即 conflict/checkpoint_entry_tokens;汇总再由这些逐条派生值重新求和核对,不符即 conflict/checkpoint_tokens,之后才套用 8,000 上限。修复后同一 wire 在 parse / deserialize / admit / publish 四层均被 conflict/checkpoint_tokens 拒绝;自报 1 而文本值 8,001 的 entry 单独被 conflict/checkpoint_entry_tokens 拒绝。
回归:同一测试内覆盖「汇总少报」「entry 自报错误」「如实 8,001 被预算本身拒绝」三种情形,均先红后绿。

顺带说明(不属于复审新发现,但为让回归可编译):按本仓既有约定给 checkpointService.js / evidenceQuery.js 补了 .d.ts 兄弟文件(threadService.d.ts 同款),否则从 TS 测试导入真实服务边界会触发 TS7016。

验证:app/runtime/contextFabric.test.ts 52/52;task-33 QA happy 20/20 assertions passed、failure 21/21 passed(均 exit 0,defects 仅为先前已披露的 evidence 分页参数缺口与已修复的脱敏保真项,非本次失败);oxlint 0 error;vue-tsc --noEmit 干净。未运行全量 vitest、浏览器 QA、live SSH/Slurm;未声称其他未验证路径无缺陷。

…d target contracts

The new evidenceQuery.d.ts declared readEvidence as returning
string | Uint8Array, but the service hands the reader to readEvidencePage
unchanged, and that function requires { bytes: Uint8Array, totalBytes } or
null: a string or raw byte answer is refused at runtime with
invalid_request/evidence_reader, while the object reader the runtime accepts
and the null reader that yields evidence_missing were both rejected by the
declaration with TS2322. resolveTarget was likewise a loose
(...args) => unknown instead of the shared TargetResolver, so a caller could
wire a resolver whose answer parseTarget refuses.

The ports are now the shared EvidenceReader and TargetResolver types, so the
declaration, the dispatcher and the runtime agree. The publish-only call
sites are wired with type-correct ports, and a new test publishes a legal
evidence checkpoint through the real service and calls
context.evidence.page: the object reader serves the bytes, the null reader
yields evidence_missing, a moved target is refused before the reader runs,
and a string reader — the shape the old declaration accepted — is refused at
runtime.

qiyuanhuakai commented Oct 19, 2026 •

Copy link
Copy Markdown
Owner Author

AI 辅助源码增量复核(dot)。

当前 head:5a00472be3e3cc5c1dcd4d30c07f0cd0182c26e1;base:b28c7476f062a2c8a7bff4a837b282ac05a99d90。发布前已重读 head、open/未合并状态与评论去重。相对上次 f2ccd12ec6a518bff07a735418f104ccf64a48f1 仅 1 个提交、2 个文件:evidenceQuery.d.ts 与 contextFabric.test.ts;运行时 JS 没有改动。

上次 reader 声明 P2的类型定义已修正,实际服务的对象/null reader 路径独立验证通过。

  • evidenceQuery.d.ts:1–10现在直接复用共享 EvidenceReader 和 TargetResolver,不再声明 string | Uint8Array 或 unknown target。它与共享契约使用同一类型,消除了此前自行重述端口造成的不一致。
  • 新增测试已穿过真实 publish → context.evidence.page,覆盖对象 reader、缺失、stale target 与非法原始字符串;原先仅 publish 的两个调用点也换成正确端口。

独立验证:Node v24.19.0 直接加载精确 head 的原始服务与共享模块,6 个定向场景通过:

  1. 对象 reader 经 build/serialize/publish/page 返回逐字节一致的 UTF-8 证据与正确 totalBytes。
  2. null reader 返回 evidence_missing。
  3. 原始 string 仍被运行时以 invalid_request/evidence_reader 拒绝。
  4. 原始 Uint8Array 同样被拒绝。
  5. target revision 过期时在 reader 调用前拒绝,reader 调用次数为 0。
  6. 跨 thread scope 同样在读取前拒绝,reader 调用次数为 0。

类型结论与执行范围分开说明:本轮是对声明直接复用共享类型的源码核对,并辅以上述真实运行时探针;当前环境没有可用 TypeScript/Vitest,因此未重新执行最小 tsc 消费者、完整 vue-tsc 或原 Vitest 套件。不能把这些未执行项目写成独立通过。实现、声明、依赖和测试共 7 份落地文件已逐项核对 Git blob。没有运行完整项目 lint/构建、浏览器 QA、真实 harness、SSH/Slurm 或进程死亡恢复。

在本次两文件增量及已验证分支内,未发现新的确定缺陷。既有工具分页参数接线缺口不在本次修改中;本结论不表示所有功能或生产接线已完成。

2026-10-19 02:50 UTC 复查:当前 head CI已 completed/failure。Validate 的 lint/type-check、tests 和 web build 均成功;失败在 Bridge installer (windows, x64) 的 Verify Windows process-tree cleanup:app/acpTerminalManager.test.ts:37:3 中 “terminates descendants of a SIGTERM-resistant terminal” 超过 10,000ms,2 文件共 11 passed / 1 failed。已读原始 job log;该测试文件和 acpTerminalManager.js 在本次 CI 合并树与上次 head 的 blob 相同,本次两文件增量未修改它们。尚未确定超时根因,不据此断言环境波动或本次修复导致。Complete CI 因下游失败而失败,发布跳过。CI 与上述独立验证分开计算。本评论不保证不存在其他缺陷,不构成正式 APPROVE/REQUEST_CHANGES。

@qiyuanhuakai

Copy link
Copy Markdown
Owner Author

针对 #189(P2,evidenceQuery 声明与真实 reader 契约相反)已修复,head f2ccd12ec → 5a00472be3e3cc5c1dcd4d30c07f0cd0182c26e1。已确认修复的两项 P2(transcriptBytes 校验、逐条 token 重算+汇总核对)未再改动。

RED(修复前,类型检查):按共享契约正确接线(EvidenceReader 对象 reader / null reader、TargetResolver)后 vue-tsc --noEmit 报 6 处 TS2322:声明只接受 string | Uint8Array,于是运行时真正接受的两种形态——{ bytes, totalBytes } 对象 reader 与表示缺失的 null reader——全部无法编译;而声明放行的 string / Uint8Array 正是运行时以 invalid_request/evidence_reader 拒绝的形态。合法 TypeScript 接线失败、运行时必拒的返回值被接受,两个方向都复现。

修复(bridge/runtime/threads/evidenceQuery.d.ts):端口直接复用共享的 EvidenceReader 与 TargetResolver 类型,不再另造端口类型——服务原样把两个函数交给 callEvidenceTool,端口契约就是共享契约,声明、分发器与运行时应答一致。

逐条证据:

  • 修复后 vue-tsc --noEmit 0 错误(修复前 6 处 TS2322)。
  • 新增测试经真实 createEvidenceQuery 走完整链路:publish 合法 evidence checkpoint 后 context.evidence.page——对象 reader 返回页面字节且 totalBytes 一致;null reader 得 { available: false, unavailableReason: 'evidence_missing' };target 已移动时在调用 reader 之前即拒(conflict/evidence_target_stale,reader 调用计数为 0);string reader 在运行时被拒(invalid_request/evidence_reader)。测试同时以共享类型标注端口接线,声明若回退即编译失败,构成回归护栏。
  • app/runtime/contextFabric.test.ts 53/53 通过(复核独立基线为 52,+1 为新增服务级测试);oxlint 对改动文件 0 告警。
  • 两处仅调用 publish 的既有接线改为类型正确的端口(resolveTarget: () => targetOne、readEvidence: () => null),断言本身未动。

未验证(如实声明):未跑全仓 vitest、完整 lint/vue-tsc 之外的范围、浏览器 QA、真实 harness/SSH/Slurm;runtimeServices/runtimeDispatch 仍无 context 服务生产接线(与复核观察一致),本次结论限于声明边界及其测试。证据:.omo/evidence/vis-v090/prfix-round5-boundaries/pr189-RED.txt / pr189-GREEN.txt。

…able graceful phase

The Windows graceful phase of stopProcessTree ran the PowerShell CIM
descendant sweep and then still waited the full grace period for the
leader to exit before escalating to force. `taskkill /T` without `/F`
cannot terminate a windowless console process (exit 128) and the CIM
sweep excludes the root, so the graceful phase provably cannot stop a
leader: the grace wait was dead time, and the sweep was redundant
because the forced `taskkill /T /F` reaps the live tree anyway. That
dead time plus the job's first powershell/Get-CimInstance cold start
put `terminates descendants of a SIGTERM-resistant terminal`
(acpTerminalManager.test.ts) past its 10s budget on the windows-x64
runner: the same blobs passed at 3333ms the day before and timed out
at 10008ms.

Extract taskkillProcessTree as the graceful probe (stopWindowsProcessTree
keeps its taskkill-plus-sweep contract for signalProcessTree and
forceStopProcessTree callers) and have stopProcessTree escalate
immediately when the probe fails. The grace period is still honoured
when the graceful taskkill succeeds, and the sweep still runs in the
forced path for a leader that already exited, the one case taskkill
cannot enumerate.
@qiyuanhuakai

qiyuanhuakai commented Oct 19, 2026 •

Copy link
Copy Markdown
Owner Author

Windows 进程树清理测试超时的根因已定位并修复,head 推进至 0b871adf8880b6a71cefd86fa32f25a077d9d70c,Bridge installer (windows, x64) 与 Complete CI 均已转绿(run 39210306512,windows-x64 2m31s 通过)。

根因(产品缺陷,非环境波动)

失败测试 acpTerminalManager.test.ts:37 “terminates descendants of a SIGTERM-resistant terminal” 超时 10s,卡在 manager.stopAll()。Windows 侧 stopProcessTree 的优雅期做两件对“无窗口 leader”注定无效的事:

  1. taskkill /T(不带 /F)无法终止无窗口控制台进程(退出码 128,本仓库自身 mock 即按 128 建模);CIM 后代扫描按构造又排除 root。因此优雅期结束时 leader 必然还活着,随后 graceMs(1500ms)等待的是一个不可能发生的退出——纯死时间。
  2. 该优雅期里的 PowerShell CIM 扫描同样是冗余劳动:随后的强制步 taskkill /T /F 会枚举并杀死整个存活进程树(包括刚被扫描杀掉的 descendant)。扫描的真正价值只在 leader 已退出的场景(taskkill 无法枚举已死 root 的后代,需靠保留的 Win32_Process.ParentProcessId)。

同类 blob 的时序证据:上一次通过的 run 606(head f2ccd12ec6)该测试 3333ms;失败的 run 624(head 5a00472be)10008ms(撞自己的 10s 预算),而同文件其余两个真实进程测试两次几乎一致(2169→2369ms、474→494ms)——说明 runner 并非整体变慢,只是本 job 第一次 powershell.exe + Get-CimInstance(Defender/AMSI 冷启动)开销变大,而死时间与冗余扫描吞掉了预算余量。同一次 run 中 windows-arm64 同款测试通过,也印证是余量问题而非确定性逻辑错误。

修复(RED-first)

新增 taskkillProcessTree 作为优雅期探针(stopWindowsProcessTree 的 taskkill+扫描契约对 signalProcessTree、forceStopProcessTree 调用方保持不变);stopProcessTree 在优雅探针失败时立即升级到强制步,不再空等 grace 期。优雅 taskkill 成功时仍保留 grace 等待,强制路径中对已退出 leader 的 CIM 扫描也保留。未删除、未收紧任何等待与断言,测试预算未动。

RED 先在 Linux 上以伪造 win32 平台 + mock windows 模块(Windows 语义已在测试注释中披露)复现缺陷:旧代码下优雅期仍调用扫描、仍烧掉 grace 期,8 个新用例失败;修复后 11/11 通过。

验证范围(如实说明)

  • 本机为 Linux,未执行 Windows gate;Windows 专属行为由上述 mock 化用例 + Windows runner 实测覆盖。
  • 本机:windows gate 同款两文件 17/17 通过;全量 vitest 599 文件 / 5931 用例通过(排除 4 个会 shell pnpm build/install 的集成文件——已用 stash 在未修改的 head 上复现同样失败,属本 worktree node_modules 符号链接的既有环境限制,与本次改动无关);vue-tsc --noEmit 与 oxlint 均通过。
  • CI:windows-x64 上该测试文件从 12889ms(含 10008ms 超时)降至 1665ms;Validate 及其余全部 job 通过。

未验证项:未在真实 Windows 上逐项测量 taskkill 各退出路径耗时;macOS/Linux 行为仅有本机与 CI 既有覆盖,无新增风险面。

Copy link
Copy Markdown
Owner Author

AI 辅助增量复核(2026-10-19 06:44 UTC)

Head:0b871adf8880b6a71cefd86fa32f25a077d9d70c;base:b28c7476f062a2c8a7bff4a837b282ac05a99d90。发布前已重读 head、评论与 open/未合并状态并去重。相对上次 5a00472be3e3cc5c1dcd4d30c07f0cd0182c26e1 为 1 个提交、4 个文件;本次修改共享进程停止层,不是 context/evidence 实现变更,不重复计算堆叠祖先问题。

结论:此前记录的 Windows x64 cleanup CI 超时已在当前运行消失;本轮增量未发现新的确定缺陷。

  • processTree.js:76–115:Windows 优雅探针失败或抛错即进入强制清理,不再先做 CIM 扫描并等待完整 grace;探针成功仍等待退出,超时才升级。POSIX 分支没有改动。
  • windowsProcessTree.js:40–70:抽出的探针只报告 taskkill 成败;完整 stopWindowsProcessTree 仍在 taskkill 失败后扫描后代。signalProcessTree/forceStopProcessTree 的调用仍使用完整清理路径;核对了 ACP manager 的 kill/release/stopAll 调用与原测试,未通过放宽原 10 秒测试预算来转绿。
  • 上次 evidenceQuery 声明修复涉及的文件本次未改动,保留此前结论;既有 context 工具分页及生产接线限制也没有因此被解决。

独立验证:Node v24.19.0 加载本 head 两份原始 JS,通过隔离 VM 注入假的 win32 平台及命令/子进程事件,10 个定向场景通过:taskkill 成功、128 失败、启动 error;强制参数与失败后的 sweep;sweep 失败传播;探针失败/抛错立即强制;优雅期正常退出不强制;优雅期超时升级;强制后仍不退出则拒绝。没有执行真实 taskkill、PowerShell 或进程终止,不能将这些模型测试表述为 Windows 原生验证。

外部 CI 已独立读取 job 原始日志:run 39210306512 completed/success,13 个 job 成功,draft release 跳过;执行树为 755ff07de2a8ffd2ffc2b009f460a1f36c2b94d7(本 head 合入上述 base)。

  • Windows x64 与 Windows arm64 cleanup gate 均 2 文件 / 17 用例通过;ACP 文件总耗时分别 1665ms、2840ms。
  • Validate lint/type-check、web build 成功;605 文件通过 / 2 跳过,5987 用例通过 / 5 跳过。

限制:本轮没有在本地执行完整 Vitest/typecheck、原生 Windows 安装或进程树测试、SSH/Slurm。CI 证实当前回归门禁通过,但不足以单独证明此前耗时上涨一定由 Defender/AMSI 冷启动造成;该精确归因未独立验证。本结论限于上述增量及已检分支,不是无缺陷保证,也不构成正式 APPROVE/REQUEST_CHANGES。

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant