@zhuxixi/pi-agent-board 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/README.md +6 -3
- package/VERIFY.md +2 -1
- package/docs/PTY_ATTACH_IMPLEMENTATION_PLAN.md +3 -1
- package/docs/superpowers/plans/2026-09-10-reader-consistency.md +115 -0
- package/docs/superpowers/plans/2026-09-14-attach-cursor-dectcem-gate.md +469 -0
- package/docs/superpowers/plans/2026-09-14-attach-snapshot.md +92 -0
- package/docs/superpowers/plans/2026-09-14-host-meta-orphan-lock.md +771 -0
- package/docs/superpowers/plans/2026-09-14-issue-106-terminal-frame-cognition.md +299 -0
- package/docs/superpowers/plans/2026-09-14-issue-113-foreground-preview-race.md +609 -0
- package/docs/superpowers/plans/2026-09-14-terminal-model.md +145 -0
- package/docs/superpowers/plans/2026-09-15-coordinator-pipe-root-normalize.md +224 -0
- package/docs/superpowers/plans/2026-09-15-lease-publish-eprem-reclaim.md +341 -0
- package/docs/superpowers/plans/2026-09-18-detach-anchor-reporter-endpoint.md +875 -0
- package/docs/superpowers/plans/2026-09-20-control-lifecycle.md +116 -0
- package/docs/superpowers/plans/2026-09-20-issue-121-perf-gate-out-of-coverage.md +517 -0
- package/docs/superpowers/specs/2026-09-14-attach-cursor-dectcem-gate-design.md +114 -0
- package/docs/superpowers/specs/2026-09-14-host-meta-orphan-lock-design.md +120 -0
- package/docs/superpowers/specs/2026-09-14-issue-106-terminal-frame-cognition-design.md +146 -0
- package/docs/superpowers/specs/2026-09-14-issue-113-foreground-preview-race-design.md +116 -0
- package/docs/superpowers/specs/2026-09-15-coordinator-pipe-root-normalize-design.md +84 -0
- package/docs/superpowers/specs/2026-09-15-lease-publish-eprem-reclaim-design.md +92 -0
- package/docs/superpowers/specs/2026-09-18-detach-anchor-reporter-endpoint-design.md +123 -0
- package/docs/superpowers/specs/2026-09-20-issue-121-perf-gate-out-of-coverage-design.md +204 -0
- package/package.json +3 -2
- package/runner/job-runner.mjs +8 -7
- package/runner/pty-runner.mjs +616 -27
- package/runner/state-coordinator.mjs +43 -17
- package/runner/state-runner.mjs +6 -5
- package/scripts/run-perf-gate.mjs +40 -0
- package/src/commands/agent-board.ts +8 -8
- package/src/commands/attach-flow.ts +5 -5
- package/src/core/control-protocol.mjs +482 -0
- package/src/core/editor-state-reporter.mjs +11 -1
- package/src/core/foreground-preview-cache.mjs +117 -0
- package/src/core/host-protocol.mjs +24 -0
- package/src/core/locks.mjs +68 -14
- package/src/core/paths.mjs +35 -3
- package/src/core/pid.mjs +32 -1
- package/src/core/pty-attach-jiggle-controller.mjs +27 -3
- package/src/core/pty-attach-reconnect.mjs +13 -6
- package/src/core/pty-attach-render.mjs +20 -0
- package/src/core/state-commands.mjs +88 -6
- package/src/core/status-consistency.mjs +98 -0
- package/src/core/store.mjs +59 -13
- package/src/core/terminal-attach-client.mjs +803 -0
- package/src/core/terminal-attach-protocol.mjs +252 -0
- package/src/core/terminal-model.mjs +222 -0
- package/src/core/terminal-snapshot.mjs +440 -0
- package/src/index.ts +12 -4
- package/src/runtime/service.mjs +284 -16
- package/src/ui/dashboard.ts +41 -33
- package/src/ui/pty-attach.ts +236 -72
- package/src/core/pty-input.mjs +0 -47
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# Plan: Canonical Terminal Model (issue #91 Phase 3, D2+D5)
|
|
2
|
+
|
|
3
|
+
Spec: `docs/superpowers/specs/2026-09-09-harden-runner-architecture-design.md` (§ D2, D5, §8.3, §9 terminal model/snapshot layers, acceptance A4/A5b/A11)
|
|
4
|
+
Parent issue: #91 (do NOT close)
|
|
5
|
+
|
|
6
|
+
## Goal
|
|
7
|
+
|
|
8
|
+
PTY runner owns a canonical terminal state: every child output chunk is parsed by `@xterm/headless` inside the runner, assigned a strictly increasing `outputSeq`, retained in a bounded in-memory ring, and can be serialized as a versioned `TerminalSnapshot` DTO with proven hydrate equivalence. The runner additionally speaks a capture-and-subscribe protocol alongside the legacy fire-and-forget broadcast — **without changing any legacy behavior** (old UI keeps working unchanged; UI switch is Phase 4).
|
|
9
|
+
|
|
10
|
+
Closes acceptance A4 (snapshot rebuildable), A5b (hydrate/合成帧等价), A11 (perf) — at the layer they live in (unit + fake-transport integration; full attach e2e A5/A5c/A6 land with the Phase 4 UI switch).
|
|
11
|
+
|
|
12
|
+
## Non-goals (later phases)
|
|
13
|
+
|
|
14
|
+
- Switching `src/ui/pty-attach.ts` to snapshot+subscribe, deleting screen.log replay as correctness source, jiggle deprecation (Phase 4)
|
|
15
|
+
- Control command lifecycle accepted/applied/observed, reconcile ordering (Phase 5)
|
|
16
|
+
- Removing `childInputLooksEmpty()` (Phase 6)
|
|
17
|
+
- D6 Windows JSON-runner (separate issue)
|
|
18
|
+
|
|
19
|
+
## Verified ground truth (do not re-litigate)
|
|
20
|
+
|
|
21
|
+
- `@xterm/headless` v6 exposes: `buffer.active.{cursorX,cursorY,baseY,length,getLine}`, `line.getCell(x)` → `getChars()/isFgPalette()/getFgColor()/isFgRGB()/isBold()/isInverse()...`, `terminal.modes` (insertMode, originMode, bracketedPasteMode, mouseTrackingMode, ...), `reset()`, `resize()`. `write()` parses **asynchronously** (verify callback support; else poll via `setImmediate`).
|
|
22
|
+
- Runner currently: `child.onData` → `appendBoundedScreenLog` + `broadcast({type:"output", data})`; socket protocol (runner→client): hello/status/output/editor_state/exit/error; (client→runner): hello/input/resize/interrupt/terminate/detach/get_status/editor_state.
|
|
23
|
+
- Canonical terminal state is **runner-lifetime memory only** — never persisted across runner restart (runner death kills child; no old-screen restoration scenario exists).
|
|
24
|
+
|
|
25
|
+
## Architecture (per spec §9 split)
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
src/core/terminal-model.mjs # model + ring (parser factory injected)
|
|
29
|
+
src/core/terminal-snapshot.mjs # capture DTO v1 / hydrate / equivalence / synthesizeFullRedrawFrame
|
|
30
|
+
src/core/terminal-attach-protocol.mjs # pure subscription state machine (send injected); runner wires it
|
|
31
|
+
runner/pty-runner.mjs # integration: feed model on child.onData, expose subscribe_terminal
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### DTO v1 (versioned, parser-independent)
|
|
35
|
+
|
|
36
|
+
```js
|
|
37
|
+
{
|
|
38
|
+
version: 1,
|
|
39
|
+
kind: "terminal_snapshot",
|
|
40
|
+
cols, rows,
|
|
41
|
+
snapshotSeq, // seq of last chunk incorporated into this snapshot
|
|
42
|
+
cursor: { x, y }, // viewport-relative
|
|
43
|
+
modes: { originMode, insertMode, bracketedPasteMode, mouseTrackingMode, ... }, // minimal closure
|
|
44
|
+
viewport: [ { text, cells: [ {ch, fg, bg, flags} ] } ], // rows of viewport (chars + attrs)
|
|
45
|
+
scrollback: [ ... ], // up to scrollbackCap lines BEFORE viewport (may be empty)
|
|
46
|
+
scrollbackTruncated: bool, // true if more scrollback existed than the cap
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Design decisions (plan-stage rulings):
|
|
51
|
+
- **Scrollback snapshot cap = 256 lines** + viewport, explicit `scrollbackTruncated` flag. Current status quo (screen.log tail hard-cut) recovers far less; unlimited scrollback DTO (2000 lines) would make snapshots heavy. Phase 4 can revisit per attach UX feedback.
|
|
52
|
+
- **Ring caps: 2048 chunks / 4 MiB** (whichever hits first), eviction records `evictedThrough` seq; subscriber needing seq beyond eviction → fresh snapshot (spec: never stitch from partial tails). **Boundary ruling (pinned by Task 1 tests)**: the ring always retains the contiguous range `(evictedThrough, lastSeq]`, so `sinceSeq === evictedThrough` means replay IS complete (not a partial tail) → replay; only `sinceSeq < evictedThrough` forces a fresh snapshot.
|
|
53
|
+
- **Perf thresholds (A11)**: feed p95 ≤ 5ms/chunk, p99 ≤ 8ms; snapshot capture ≤ 50ms; hydrate ≤ 100ms (80×24 + 256 scrollback on dev hardware). Ring overflow must produce a resnapshot signal, never silent data loss.
|
|
54
|
+
|
|
55
|
+
### Wire route decision (open decision A/B — resolved by Task 2 prototype evidence)
|
|
56
|
+
|
|
57
|
+
- **Route A**: wire carries the DTO; UI hydrate adapter synthesizes VT locally.
|
|
58
|
+
- **Route B**: wire carries a synthesized full-redraw byte frame (from the same canonical state) + minimal meta; UI feeds bytes to existing xterm unchanged.
|
|
59
|
+
- Both routes share ONE synthesis implementation in `terminal-snapshot.mjs` (frame synthesis is required for hydrate testing anyway: hydrate == write frame into an independent parser). The decision is only *where synthesis runs* (runner vs UI) and *what the versioned wire contract is*.
|
|
60
|
+
- Task 2 must record: equivalence pass rate on torture fixtures, payload size, latency, UI-side delta estimate. Decision + evidence appended to this plan.
|
|
61
|
+
|
|
62
|
+
### Capture-and-subscribe protocol (additive, legacy-safe)
|
|
63
|
+
|
|
64
|
+
- Client → runner: `{type:"subscribe_terminal", sinceSeq?: number}`.
|
|
65
|
+
- First subscribe (no sinceSeq): runner synchronously captures snapshot at current seq S, sends `{type:"snapshot_begin", snapshotSeq:S, cols, rows}` + payload chunk(s) + `{type:"snapshot_end", nextSeq:S+1}`, then live `{type:"output", seq, data}` for seq > S.
|
|
66
|
+
- Reconnect with sinceSeq=X: if `X >= evictedThrough` → replay ring X+1..now (bounded write, then live); else send fresh snapshot with `{resnapshot:true}` marker. (`X > lastSeq` — client ahead of runner — is a protocol-level decision deferred to Task 3, the model primitive deliberately returns empty-not-evicted.)
|
|
67
|
+
- Atomicity: async capture + ring retention + catch-up flush guarantee gap-free/dup-free delivery (stronger and tested; supersedes the earlier "same synchronous tick" sketch). In the current single-threaded runtime, chunks arriving during the capture window are folded into the DTO (microtask-atomic continuation), and the catch-up flush is defense-in-depth that never fires — Phase-4 authors should NOT expect in-window `output` messages between `begin` and `end` as a common path. A snapshot CAN be interrupted: `snapshot_begin` + `snapshot_frame` may be followed by `resnapshot_required` (ring pressure) instead of `snapshot_end` — tested, safe, client must handle.
|
|
68
|
+
- Legacy `output` broadcast gains an additive `seq` field (old clients ignore unknown fields). No legacy message semantics change.
|
|
69
|
+
- Runner restart: model empty ⇒ snapshot payload `{empty:true}` ("host starting" baseline — new child's first output establishes the new baseline; per spec, no old-screen restoration).
|
|
70
|
+
|
|
71
|
+
## Tasks (bite-sized, commit per task)
|
|
72
|
+
|
|
73
|
+
### Task 1 — terminal model core (`src/core/terminal-model.mjs`)
|
|
74
|
+
- `createTerminalModel({cols, rows, scrollback, ringChunkCap=2048, ringByteCap=4MiB, parserFactory})` — parserFactory injectable (default: `@xterm/headless` Terminal).
|
|
75
|
+
- `feedOutput(model, chunk)` → assigns `seq` (strict monotonic from 1), feeds parser (async write — model exposes `whenIdle()`/write-callback accounting), appends `{seq, data}` to ring with eviction accounting (`evictedThrough`, `ringBytes`).
|
|
76
|
+
- `ringChunksAfter(model, seq)` → chunks with seq > given, or `{evicted:true}` marker.
|
|
77
|
+
- Pure-ish: no fs, no sockets.
|
|
78
|
+
- Tests (`test/terminal-model.test.mjs`): seq monotonicity across split escape sequences (chunks that split CSI/OSC across feeds — parser must still get exact byte order); ring eviction advances `evictedThrough`; byte-cap eviction; `ringChunksAfter` boundaries.
|
|
79
|
+
- **Commit early**: land model + passing unit tests before touching anything else.
|
|
80
|
+
|
|
81
|
+
### Task 2 — snapshot DTO + hydrate + frame synthesis + ROUTE DECISION (`src/core/terminal-snapshot.mjs`)
|
|
82
|
+
- `captureTerminalSnapshot(model, {scrollbackCap=256})` → DTO v1 (grid walk: chars + fg/bg/flags; cursor; modes minimal closure; `await model.whenIdle()` before capture).
|
|
83
|
+
- `synthesizeFullRedrawFrame(model|dto)` → VT bytes: DECSET mode set, scrollback replay via ordered writes + newlines to push into scrollback, then viewport rows with absolute cursor positioning (CUP) + SGR attrs per run, cursor to saved position. One implementation, used by both hydrate and (if chosen) the wire.
|
|
84
|
+
- `hydrateTerminalSnapshot(dto, parserFactory)` → independent model fed by the frame; `assertSnapshotEquivalence(a, b)` grid/cursor/modes walker for tests.
|
|
85
|
+
- Torture fixtures (A4/A5b): split CSI/OSC across chunk boundaries, relative cursor moves (CUB/CUP/CUP-relative), scroll-up + scrollback spillover, SGR attrs (bold/inverse/palette/RGB fg+bg), wide/combining chars smoke, cursor park, resize-after-content, **and wrap-pending cursor (REQUIRED, Task-1 review ruling): plain write to the last column leaves parser cursorX===cols — capture must encode this state faithfully (no silent clamping) and the chosen encoding must survive `assertSnapshotEquivalence` on that fixture, else Route A falls back per plan**.
|
|
86
|
+
- Perf micro-checks folded into Task 4.
|
|
87
|
+
- **Record route decision** (A vs B) + evidence table appended to this plan → gates Task 3 wire payload shape.
|
|
88
|
+
|
|
89
|
+
### Task 3 — runner integration + capture-and-subscribe protocol (`src/core/terminal-attach-protocol.mjs`, `runner/pty-runner.mjs`)
|
|
90
|
+
- Pure state machine: `createTerminalSubscription({model, send, now})`; `handleTerminalMessage(msg)` for `subscribe_terminal`; internal `onOutput(seq, data)` fan-out (per-socket lastSeq tracking; gap → resnapshot marker once).
|
|
91
|
+
- Runner: create model per host (cols/rows from host config, scrollback 2000 parser-side); `child.onData` additionally feeds model (screen.log + legacy broadcast unchanged); `resize` also `model.resize()`; wire `subscribe_terminal` via the state machine; `output` broadcast gains additive `seq`.
|
|
92
|
+
- Backpressure note: per-socket writes are stream-buffered by node; slow consumer just grows its socket buffer (same as today's broadcast) — acceptable, unchanged semantics.
|
|
93
|
+
- Tests (`test/terminal-attach-protocol.test.mjs`, fake send/transport): first-subscribe snapshot+S+1 continuity; output injected between capture and live switch lands exactly once (A5 gap/dup at protocol layer); reconnect sinceSeq replay; ring-evicted → fresh snapshot + resnapshot marker; legacy `output` messages still carry data (compat pin); runner-restart empty snapshot `{empty:true}`.
|
|
94
|
+
- Keep zero behavior change for legacy clients (full suite green is the gate).
|
|
95
|
+
|
|
96
|
+
### Task 4 — perf A11 + acceptance sweep
|
|
97
|
+
- `test/terminal-model-perf.test.mjs`: 750 chunks (60s × 12.5fps equivalent) back-to-back + paced variant (setInterval 80ms, shortened to ~5s wall); measure per-chunk feed latency (p95/p99), snapshot capture + hydrate latency at end-state; assert thresholds; ring overflow behavior (feed past caps mid-stream, assert resnapshot signal + no crash).
|
|
98
|
+
- Full regression: `node --test test/*.test.mjs` (note: NOT `test/`), `npm run typecheck`, zero stray coordinators.
|
|
99
|
+
- Update this plan's "Route decision" section with final Task 2/4 evidence if anything shifted.
|
|
100
|
+
|
|
101
|
+
## Verification per task
|
|
102
|
+
|
|
103
|
+
Every task: targeted tests green → full suite green → typecheck → conventional commit (explicit `git add <files>`). Task reviews per SDD; whole-branch final review before PR.
|
|
104
|
+
|
|
105
|
+
## Risks
|
|
106
|
+
|
|
107
|
+
- **xterm write-async accounting**: if the write callback is unavailable, poll `setImmediate` until parser idle; model must expose deterministic `whenIdle()` for capture correctness (capture reads buffer post-parse).
|
|
108
|
+
- **Frame synthesis completeness** (route B risk): modes/scrollback edge fidelity — torture fixtures exist precisely to quantify; route A is the fallback if equivalence < 100% on any fixture.
|
|
109
|
+
- **Perf thresholds too tight on CI hardware**: thresholds are p95/p99 with generous headroom; if CI shows systematic misses, re-baseline with evidence (not silently relax).
|
|
110
|
+
|
|
111
|
+
## Route decision (Task 2 evidence)
|
|
112
|
+
|
|
113
|
+
**推荐 Route B**(wire 携带 runner 合成的全量重绘帧 + `{frameVersion:1}` 元数据;UI 原样喂现有 xterm)。
|
|
114
|
+
|
|
115
|
+
| 证据项 | 实测(80×24 + 256sb 真实负载,real parser) |
|
|
116
|
+
|---|---|
|
|
117
|
+
| torture corpus 等价通过率 | **12/12**(A/B 共享同一合成实现;split CSI/OSC、相对光标、滚动溢出、SGR palette+RGB、宽/组合字符、cursor park、resize 后、wrap-pending×2、wraparound off、scroll region、empty) |
|
|
118
|
+
| wire 负载 | DTO JSON **202,187 B** vs 全量重绘帧 **28,850 B**(**7.0×**) |
|
|
119
|
+
| capture 延迟(runner 侧,两路线同) | 2.2–6.8 ms(阈值 50ms) |
|
|
120
|
+
| 帧合成延迟(B: runner 侧 / A: UI 侧) | 3.8–4.8 ms(阈值 50ms) |
|
|
121
|
+
| hydrate 总延迟(A 的 UI 路径 = 合成+解析) | 6.2–10.2 ms(阈值 100ms) |
|
|
122
|
+
| UI 侧改动 | B ≈ 0(喂现有 xterm,正是 Phase 4「本地 buffer 变可丢弃缓存」的形态);A 需在 UI 侧引入合成适配器(同一模块 import,但 CPU 与失败面移到 UI 进程) |
|
|
123
|
+
|
|
124
|
+
理由(一句话):**7 倍 wire 差距发生在每次 attach/gap 恢复的热路径上,而 B 的 UI 侧改动≈0、CPU 差异毫秒级不可感知;DTO 仍是版本化内部契约(D5 达成),wire 版本字段为将来 Route A 协商留了加法空间。**
|
|
125
|
+
|
|
126
|
+
实现备注:
|
|
127
|
+
- `viewport` 行直接是 cell 数组(plan 草图里的 `text` 字段冗余未采用——cells 已携带字符);cell 三态编码:默认属性=纯字符串,显式属性=`[ch,fg,bg,flags]`,宽字符续格=`""`(由前导宽字符再生)。
|
|
128
|
+
- 颜色编码:-1=默认,0-255=调色板,`0x1000000|rgb`=直色(marker 位与调色板不相交)。
|
|
129
|
+
- 已知边界:DECSTBM scroll region 状态不在 modes 闭包内(region **内容**等价已测,region 设置本身不往返——与 spec 最小闭包一致,记入残留);`synchronizedOutputMode` 忠实还原。
|
|
130
|
+
- 帧在**脏终端**上自洽(先 DECSTR + 2J/3J + region 重置再发内容)——wire 复用前提已测。
|
|
131
|
+
|
|
132
|
+
## Residual ledger (Phase 3)
|
|
133
|
+
|
|
134
|
+
Review rulings and known edges carried forward (Phase 4 must consult before switching the UI):
|
|
135
|
+
|
|
136
|
+
- **Modes closure edges** (Task 2/3 review rulings): DECSTBM scroll region, charset designation (SO/SI), tab stops (HTS/TBC), and DECSC saved cursor are **outside** the snapshot modes closure — captured content is content-equivalent, region/tab/saved-cursor state itself does not round-trip. Consistent with the spec's "minimal closure" ruling; revisit only if a Phase 4 attach bug traces here.
|
|
137
|
+
- **Test flake (environment) — FIXED**: load-sensitive `waitFor` timeouts (3s default) in `pty-runner.integration.test.mjs` flaked once the A11 perf file added parallel CPU load (spawn + first-output legitimately >3s under full-suite parallelism). Helper default raised to 10s (all call sites are "eventually" predicates, none timing-bound); two consecutive full-suite green runs after the bump. Earlier two-file combo 3s waitFor flake was the same class.
|
|
138
|
+
- **Phase 4 client-contract nuances**: (a) the replay path (`subscribe_terminal` with `sinceSeq`) sends raw `output` messages with **no snapshot_begin framing** — a reconnecting client must treat replay output as trusted continuation; (b) an empty model with `sinceSeq: 0` replies zero messages until the first live chunk (client must tolerate an empty replay); (c) `frameVersion` (wire) and `TERMINAL_SNAPSHOT_VERSION` (DTO) are deliberately independent axes.
|
|
139
|
+
- **Cosmetic**: `snapshot_begin` header carries dead stores (fields overwritten before send) in `startSnapshot` — harmless, not user-visible.
|
|
140
|
+
- **A11 perf numbers measured** (dev hardware, node --test, deterministic stream): burst 750 chunks in ~870ms — feed p50=1.13ms / p95=1.26ms / p99=1.66ms (limits 5/8); capture 80×24+256sb = 7.1ms (limit 50); hydrate = 12.7ms (limit 100); paced 60×80ms — p95=1.33 / p99=4.08 (timer-coalescing bump, still <50% of limit). Ring overflow (8-chunk cap, 200 chunks): eviction tracked, evicted cursor → `snapshot_begin.resnapshot:true` begin/end path, hydrate equivalence holds — no silent loss. Route decision unaffected by Task 4 numbers.
|
|
141
|
+
|
|
142
|
+
### Final-review P2 fixes (whole-branch review)
|
|
143
|
+
- `resnapshot` marker now set on the empty-baseline path too (marker contract consistent across resnapshot entry points)
|
|
144
|
+
- Per-socket write guard restored in both mains' output fan-out (synchronous throw can never reach the crash path)
|
|
145
|
+
- Phase-4 contract additions: snapshots can be interrupted (`resnapshot_required` after `snapshot_frame` instead of `snapshot_end`); old runners silently ignore `subscribe_terminal` — Phase-4 clients must detect missing snapshot response and fall back to legacy; parser-exception containment in the runner + one firehose stress probe recommended before Phase 4 UI switch
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# Coordinator Pipe-Name Root Normalization Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **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.
|
|
4
|
+
|
|
5
|
+
**Goal:** 让 win32 上的 coordinator 命名管道名对 root 的写法不敏感(与锁路径同一归一化口径),消除「同一逻辑 root → 同一把锁 + 两根管道」造成的面板永久失联(issue #124)。
|
|
6
|
+
|
|
7
|
+
**Architecture:** 在 `coordinatorEndpointPathFor` 的 **win32 分支** hash 前对 root 做 `path.resolve` 归一化 —— 纯函数内部单点修复,client 与 server 两侧同时生效;POSIX 分支不动(其 socket 是文件系统路径,`path.join` 已归一化)。
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Node ESM、`node:test`、零新依赖。
|
|
10
|
+
|
|
11
|
+
**Spec:** `docs/superpowers/specs/2026-09-15-coordinator-pipe-root-normalize-design.md`
|
|
12
|
+
|
|
13
|
+
## Global Constraints
|
|
14
|
+
|
|
15
|
+
- 生产改动**仅** `src/core/paths.mjs`,且**仅 win32 分支**;POSIX 行为零变化。
|
|
16
|
+
- **向后兼容**:规范形式的 hash 必须不变 —— `path.resolve(canonical) === canonical`(spec §2.1 已实测)。任何会改变规范形式 hash 的做法(如大小写折叠)一律不做。
|
|
17
|
+
- 非目标:大小写归一化、root 入口统一归一化(`defaultRoot`/runner argv)、复用或改动 #114 的锁逻辑。
|
|
18
|
+
- 测试命令在 worktree 根目录执行:`node --test test/<file>.test.mjs`。
|
|
19
|
+
- Windows 全量基线(`628e42b`):883 tests / 846 pass / 36 fail(既有环境类失败),修复后失败集不得新增。
|
|
20
|
+
- commit 用 conventional commits;每个 task 独立 commit。
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
### Task 1: 归一化实现 + unit 不变量(验收 A1–A4)
|
|
25
|
+
|
|
26
|
+
**Files:**
|
|
27
|
+
- Modify: `src/core/paths.mjs`(win32 分支,+~5 行)
|
|
28
|
+
- Test: `test/socket-path.test.mjs`(新增 import + 追加 ~45 行)
|
|
29
|
+
|
|
30
|
+
**Interfaces:**
|
|
31
|
+
- Produces: 无新导出;`coordinatorEndpointPathFor(platform, root)` 契约加强 —— win32 下返回值对 `root` 的写法不敏感。
|
|
32
|
+
- Consumes: 已有的 `path.resolve`(`node:path` 已 import)、`createHash`(已 import)。
|
|
33
|
+
|
|
34
|
+
- [ ] **Step 1: Write the failing tests**
|
|
35
|
+
|
|
36
|
+
在 `test/socket-path.test.mjs` 第 4 行的 import 中加入 `coordinatorEndpointPathFor`:
|
|
37
|
+
|
|
38
|
+
```js
|
|
39
|
+
import { controlPipeName, controlSocketPath, controlSocketPathFor, coordinatorEndpointPathFor, hostConfigPathFor, hostEndpointPathFor, viewDir } from "../src/core/paths.mjs";
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
在文件末尾追加:
|
|
43
|
+
|
|
44
|
+
```js
|
|
45
|
+
// ---- coordinator endpoint root-form invariance (issue #124) -----------------
|
|
46
|
+
|
|
47
|
+
test("coordinator pipe name is invariant to the root spelling (win32, issue #124)", () => {
|
|
48
|
+
const forms = [
|
|
49
|
+
"C:\\root\\board",
|
|
50
|
+
"C:/root/board",
|
|
51
|
+
"C:\\root\\board\\",
|
|
52
|
+
"C:\\root\\.\\board",
|
|
53
|
+
];
|
|
54
|
+
const names = forms.map((r) => coordinatorEndpointPathFor("win32", r));
|
|
55
|
+
for (const n of names) {
|
|
56
|
+
assert.match(n, /^\\\\.\\pipe\\agent-board-coordinator-[0-9a-f]{16}$/);
|
|
57
|
+
assert.ok(n.length <= 256, "named pipe name must fit the Windows 256-char limit");
|
|
58
|
+
}
|
|
59
|
+
assert.equal(new Set(names).size, 1, "one logical root must map to one pipe name regardless of spelling");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("coordinator pipe name still isolates distinct roots (win32)", () => {
|
|
63
|
+
assert.notEqual(
|
|
64
|
+
coordinatorEndpointPathFor("win32", "C:\\root\\board-a"),
|
|
65
|
+
coordinatorEndpointPathFor("win32", "C:\\root\\board-b"),
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("coordinator endpoint keeps POSIX semantics unchanged", () => {
|
|
70
|
+
const expected = join("/tmp/root", "coordinator.sock");
|
|
71
|
+
assert.equal(coordinatorEndpointPathFor("linux", "/tmp/root"), expected);
|
|
72
|
+
assert.equal(coordinatorEndpointPathFor("darwin", "/tmp/root"), expected);
|
|
73
|
+
assert.equal(coordinatorEndpointPathFor("linux", "/tmp/root/"), expected, "path.join already normalizes on POSIX");
|
|
74
|
+
});
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
- [ ] **Step 2: Run tests to verify the invariance test fails**
|
|
78
|
+
|
|
79
|
+
Run: `node --test test/socket-path.test.mjs`
|
|
80
|
+
Expected: 第 1 个新用例 **FAIL**(`Set.size` = 4 ≠ 1);第 2、3 个新用例 PASS(它们是回归护栏)。
|
|
81
|
+
|
|
82
|
+
- [ ] **Step 3: Implement the normalization**
|
|
83
|
+
|
|
84
|
+
`src/core/paths.mjs` 中 `coordinatorEndpointPathFor` 的 win32 分支改为:
|
|
85
|
+
|
|
86
|
+
```js
|
|
87
|
+
export function coordinatorEndpointPathFor(platform, root) {
|
|
88
|
+
if (platform === "win32") {
|
|
89
|
+
// Normalize before hashing: the pipe name must be invariant to the root's
|
|
90
|
+
// spelling (C:/x vs C:\x, trailing separators, dot segments). The lock path
|
|
91
|
+
// derived from the same root already is (via path.join); a mismatch yields
|
|
92
|
+
// "same lock, two pipes" — the panel probes a pipe nobody bound, spawns
|
|
93
|
+
// replacements that cannot take the held lease, and locks itself out
|
|
94
|
+
// (issue #124). resolve() is idempotent on canonical roots, so coordinators
|
|
95
|
+
// already deployed keep their pipe name and need no restart.
|
|
96
|
+
const normalized = path.resolve(root);
|
|
97
|
+
const hash = createHash("sha256").update(normalized).digest("hex").slice(0, 16);
|
|
98
|
+
return `\\\\.\\pipe\\agent-board-coordinator-${hash}`;
|
|
99
|
+
}
|
|
100
|
+
return path.join(root, "coordinator.sock");
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
(保持函数内注释对 256 字符限制的既有说明;POSIX 分支逐字节不变。)
|
|
105
|
+
|
|
106
|
+
- [ ] **Step 4: Run tests to verify they pass**
|
|
107
|
+
|
|
108
|
+
Run: `node --test test/socket-path.test.mjs`
|
|
109
|
+
Expected: 全部 PASS(含 3 个新用例)。
|
|
110
|
+
|
|
111
|
+
- [ ] **Step 5: Commit**
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
git add src/core/paths.mjs test/socket-path.test.mjs
|
|
115
|
+
git commit -m "fix(paths): normalize root before hashing the win32 coordinator pipe name (issue #124)"
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
### Task 2: 跨写法端到端互通(验收 A5)
|
|
121
|
+
|
|
122
|
+
**Files:**
|
|
123
|
+
- Test: `test/coordinator-client.test.mjs`(追加 ~30 行)
|
|
124
|
+
|
|
125
|
+
**Interfaces:**
|
|
126
|
+
- Consumes: 既有 `ensureCoordinator(root, { runnerScript })`、`freshRoot()`、`track(pid)`、`cleanupRoot(root)`、`COORDINATOR_SCRIPT`(同文件已定义)。
|
|
127
|
+
- Produces: 无生产接口。
|
|
128
|
+
|
|
129
|
+
- [ ] **Step 1: Write the failing test**
|
|
130
|
+
|
|
131
|
+
在 `test/coordinator-client.test.mjs` 中(`ensureCoordinator reclaims a stale identity-less orphan lease` 用例之后)追加:
|
|
132
|
+
|
|
133
|
+
```js
|
|
134
|
+
test("a coordinator started under one root spelling serves clients using another (issue #124)", async (t) => {
|
|
135
|
+
const root = freshRoot(); // win32: mkdtempSync returns a backslash path
|
|
136
|
+
t.after(async () => {
|
|
137
|
+
await cleanupRoot(root);
|
|
138
|
+
});
|
|
139
|
+
const altRoot = process.platform === "win32" ? root.replace(/\\/g, "/") : `${root}/`;
|
|
140
|
+
assert.notEqual(root, altRoot, "the two spellings must actually differ as strings");
|
|
141
|
+
|
|
142
|
+
// the coordinator runs under altRoot; the panel client uses root
|
|
143
|
+
const started = await ensureCoordinator(altRoot, { runnerScript: COORDINATOR_SCRIPT });
|
|
144
|
+
assert.equal(started.ok, true, "coordinator must start under the alternate spelling");
|
|
145
|
+
track(started.pid);
|
|
146
|
+
|
|
147
|
+
const ensured = await ensureCoordinator(root, { runnerScript: COORDINATOR_SCRIPT });
|
|
148
|
+
assert.equal(ensured.ok, true, "a client using the other spelling must reach a coordinator");
|
|
149
|
+
assert.equal(ensured.instanceId, started.instanceId, "both spellings must resolve to ONE endpoint and owner");
|
|
150
|
+
});
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
- [ ] **Step 2: Prove the test pins the fix (revert check)**
|
|
154
|
+
|
|
155
|
+
临时把 Task 1 的实现改回未归一化形态(`const hash = createHash("sha256").update(String(root))...`),运行:
|
|
156
|
+
|
|
157
|
+
Run: `node --test test/coordinator-client.test.mjs`
|
|
158
|
+
Expected: 新用例 **FAIL**(第二个 `ensureCoordinator` 探测的是另一根管道:Windows 上 `ENOENT` → spawn 新实例 → 抢锁失败 → 约 10s 后返回 `{ok:false, error:"coordinator_unavailable"}`;POSIX 上两写法本就同一 socket,故该回归守卫在 POSIX 上恒定通过)。确认失败后恢复 Task 1 的实现。
|
|
159
|
+
|
|
160
|
+
- [ ] **Step 3: Run tests to verify they pass**
|
|
161
|
+
|
|
162
|
+
Run: `node --test test/coordinator-client.test.mjs`
|
|
163
|
+
Expected: 全部 PASS(含新用例)。
|
|
164
|
+
|
|
165
|
+
- [ ] **Step 4: Commit**
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
git add test/coordinator-client.test.mjs
|
|
169
|
+
git commit -m "test(coordinator): cross-spelling endpoint interop e2e (issue #124)"
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
---
|
|
173
|
+
|
|
174
|
+
### Task 3: 全量回归与验收记录(验收 A6)
|
|
175
|
+
|
|
176
|
+
**Files:** 无代码改动(验证任务)。
|
|
177
|
+
|
|
178
|
+
- [ ] **Step 1: 目标测试文件**
|
|
179
|
+
|
|
180
|
+
Run: `node --test test/socket-path.test.mjs`
|
|
181
|
+
Expected: 0 fail(含 Task 1 新增用例)。
|
|
182
|
+
|
|
183
|
+
- [ ] **Step 2: coordinator 套件**
|
|
184
|
+
|
|
185
|
+
Run: `node --test test/coordinator-client.test.mjs test/state-coordinator.integration.test.mjs test/coordinator-journal.test.mjs`
|
|
186
|
+
Expected: 与基线相比无新增失败(`state-coordinator.integration` 的 5 个 Windows 环境失败为既有项,需逐名核对)。
|
|
187
|
+
|
|
188
|
+
- [ ] **Step 3: 类型检查**
|
|
189
|
+
|
|
190
|
+
Run: `npm run typecheck`
|
|
191
|
+
Expected: 零错误。
|
|
192
|
+
|
|
193
|
+
- [ ] **Step 4: 全量回归**
|
|
194
|
+
|
|
195
|
+
Run: `npm test 2>&1 | tail -50`
|
|
196
|
+
Expected: 失败集不新增(基线 `628e42b`:883 / 846 pass / 36 fail)。逐名比对失败清单。
|
|
197
|
+
|
|
198
|
+
- [ ] **Step 5: 记录验收结果到 issue**
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
gh issue comment 124 --repo zhuxixi/pi-agent-board --body "## 实现完成:验收结果 A1–A6
|
|
202
|
+
<贴各命令与结果摘要>"
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
- [ ] **Step 6: 无文件改动则无 commit**
|
|
206
|
+
|
|
207
|
+
若 Step 1–4 产生了临时产物,确认未 stage 后删除。
|
|
208
|
+
|
|
209
|
+
---
|
|
210
|
+
|
|
211
|
+
## Self-Review
|
|
212
|
+
|
|
213
|
+
- **Spec coverage**:A1 → Task 1 Step 1 第 1 用例;A2 → 第 2 用例;A3 → 第 1 用例的长度/前缀断言;A4 → 第 3 用例;A5 → Task 2;A6 → Task 3。spec 无非目标项被实现(无大小写折叠、无 POSIX 改动、无入口归一化)。
|
|
214
|
+
- **Placeholder scan**:无 TBD/TODO;测试与实现均为完整代码。
|
|
215
|
+
- **Type consistency**:`coordinatorEndpointPathFor(platform, root)` 签名不变(仅内部归一化);测试 helper 名称(`freshRoot`/`track`/`cleanupRoot`/`COORDINATOR_SCRIPT`)与既有定义一致。
|
|
216
|
+
- **向后兼容护栏**:Task 1 第 2 用例(隔离保留)与 Task 2 的 revert-check 共同保证"修复真的修好了"且没有把不同 root 折叠成一个。
|
|
217
|
+
|
|
218
|
+
---
|
|
219
|
+
|
|
220
|
+
## Execution notes
|
|
221
|
+
|
|
222
|
+
- Commit `06a416a` originally implemented Task 1 Step 3 verbatim with `path.resolve(root)`.
|
|
223
|
+
- **Review round 1 (Critical, plan-mandated)** flagged that as host-dependent: ambient `path.resolve` has no Windows drive-letter/dot-segment semantics on POSIX hosts, so the four spellings produce four different hashes on ubuntu CI and the Task 1 invariance assertion fails on the merge gate. Controller ruling: amend to `path.win32.resolve(root)`.
|
|
224
|
+
- `path.win32.resolve(root)` is host-independent and on Windows hosts `path === path.win32`, so the emitted pipe name is byte-identical (canonical root still `0af89859a5276f1c` — no coordinator restart needed). Applied in `fix(paths): use win32 resolve semantics for the coordinator pipe hash (issue #124)`.
|