@zhuxixi/pi-agent-board 0.6.2 → 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 +43 -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-08-issue-11-attach-runtime-desync-heal.md +917 -0
- package/docs/superpowers/plans/2026-09-09-harden-runner-architecture.md +603 -0
- package/docs/superpowers/plans/2026-09-09-single-writer-completion.md +252 -0
- 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-07-issue-11-attach-runtime-desync-heal-design.md +130 -0
- package/docs/superpowers/specs/2026-09-09-harden-runner-architecture-design.md +298 -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-legacy.mjs +68 -0
- package/runner/job-runner.mjs +371 -67
- package/runner/pty-runner-legacy.mjs +50 -0
- package/runner/pty-runner.mjs +685 -58
- package/runner/state-coordinator.mjs +429 -0
- package/runner/state-runner.mjs +90 -15
- 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/commands/bg.ts +2 -1
- package/src/core/control-protocol.mjs +482 -0
- package/src/core/coordinator-client.mjs +313 -0
- package/src/core/coordinator-journal.mjs +282 -0
- package/src/core/coordinator-protocol.mjs +12 -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/launch.mjs +15 -0
- package/src/core/locks.mjs +68 -14
- package/src/core/paths.mjs +48 -0
- package/src/core/pid.mjs +32 -1
- package/src/core/pty-attach-jiggle-controller.mjs +83 -6
- package/src/core/pty-attach-reconnect.mjs +13 -6
- package/src/core/pty-attach-render.mjs +50 -0
- package/src/core/state-commands.mjs +699 -0
- 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/core/types.mjs +2 -0
- package/src/index.ts +12 -4
- package/src/runtime/service.mjs +694 -121
- package/src/ui/dashboard.ts +67 -92
- package/src/ui/pty-attach.ts +298 -72
- package/src/core/pty-input.mjs +0 -47
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
# Issue 113 — Foreground Preview Read-Your-Writes Cache Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** 让交互式前台会话的 `latestAssistantPreview` / `lastAgentActivityAt` 不再被 `agent_end` 的过期磁盘重建覆盖——idle 行恢复显示 assistant 最后回复的首句,而不是 "Needs instructions"。
|
|
6
|
+
|
|
7
|
+
**Spec:** `docs/superpowers/specs/2026-09-14-issue-113-foreground-preview-race-design.md`(v2,含规则证据链与备选否决)
|
|
8
|
+
|
|
9
|
+
**Architecture:** 新增一个进程内 read-your-writes 缓存模块(模块级单例,因为 `serviceFor()` 每事件新建 service 实例)。`writeForegroundState` 把投影中的非空 preview/活动时间戳记入缓存(非空总是覆盖、空值永不覆盖);`syncRowEvent` 在每次从磁盘重建 status 后回填空字段。归档的两个真实写入点清理缓存。coordinator-off 直写路径行为不变。
|
|
10
|
+
|
|
11
|
+
**Tech Stack:** 纯 Node ESM `.mjs`,`node --test`,无新依赖;coordinator 测试复用 `test-support/ensure-coordinator-helper.mjs`。
|
|
12
|
+
|
|
13
|
+
## Global Constraints
|
|
14
|
+
|
|
15
|
+
- `$WT=/home/elling/git-repo/github/pi-agent-board/.pi/worktrees/issue-113-foreground-preview-cache`;所有读写用 `$WT` 绝对路径,git 一律 `git -C $WT ...`;**绝不写主仓库**。
|
|
16
|
+
- 文件缩进用 tab;纯 ESM `.mjs`;JSDoc 类型注释与仓库现有风格一致。
|
|
17
|
+
- 测试命令 `node --test test/<file>.mjs`(聚焦)与 `npm run typecheck && npm test`(全量)。
|
|
18
|
+
- commit 用英文 conventional commits;`git add <显式文件>`,禁止 `git add -A`。
|
|
19
|
+
- **缓存必须是模块级单例**(`serviceFor()` 每次调用都 `createService`,实例级缓存会失效)。
|
|
20
|
+
- 测试隔离:模块级缓存跨测试存活,service 测试必须在 `beforeEach` 调用 `foregroundPreviewCache.clear()`。
|
|
21
|
+
- 不改 coordinator 协议、`state-commands.mjs` 决策语义、legacy 路径。
|
|
22
|
+
- 只覆盖 `latestAssistantPreview` / `lastAgentActivityAt` 两个字段(其余字段见 spec 非目标)。
|
|
23
|
+
|
|
24
|
+
## 验收映射(spec 验收矩阵 ↔ plan task,双向可追溯)
|
|
25
|
+
|
|
26
|
+
| Task | 验收 ID | 交付物 |
|
|
27
|
+
|---|---|---|
|
|
28
|
+
| Task 1 | A1, A2 | `src/core/foreground-preview-cache.mjs` + 单元测试 |
|
|
29
|
+
| Task 2 | A3, A5, A7 | service 接线(remember/backfill)+ 延迟落盘竞态测试 + 测试隔离 beforeEach |
|
|
30
|
+
| Task 3 | A6 | 两个归档站点 forget + 路径测试 |
|
|
31
|
+
| Task 4 | A4 | 真实 coordinator 不变式测试 |
|
|
32
|
+
| Task 5 | A8, U1 | 全量回归 + 验收对账 + 用户实测清单 |
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
### Task 1: 缓存模块与单元测试
|
|
37
|
+
|
|
38
|
+
**Files:**
|
|
39
|
+
- Create: `$WT/src/core/foreground-preview-cache.mjs`
|
|
40
|
+
- Test: `$WT/test/foreground-preview-cache.test.mjs`
|
|
41
|
+
|
|
42
|
+
**Interfaces:**
|
|
43
|
+
- Consumes: 无。
|
|
44
|
+
- Produces:
|
|
45
|
+
- `createForegroundPreviewCache()` → `{ remember(viewId, projection): void, backfill(viewId, status): boolean, forget(viewId): boolean, clear(): void, size(): number }`
|
|
46
|
+
- `foregroundPreviewCache`(模块级共享单例)
|
|
47
|
+
- 规则:`remember` 非空覆盖、空值不覆盖;`backfill` 仅填空字段、磁盘非空优先,返回是否发生回填。
|
|
48
|
+
|
|
49
|
+
- [x] **Step 1: 写失败测试**
|
|
50
|
+
|
|
51
|
+
创建 `$WT/test/foreground-preview-cache.test.mjs`:
|
|
52
|
+
|
|
53
|
+
```js
|
|
54
|
+
import assert from "node:assert/strict";
|
|
55
|
+
import { test } from "node:test";
|
|
56
|
+
import { createForegroundPreviewCache } from "../src/core/foreground-preview-cache.mjs";
|
|
57
|
+
|
|
58
|
+
/** @returns {{ latestAssistantPreview: string, lastAgentActivityAt: number|null }} */
|
|
59
|
+
function emptyStatus() {
|
|
60
|
+
return { latestAssistantPreview: "", lastAgentActivityAt: null };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
test("remember stores a non-empty projection; backfill restores it into an empty status", () => {
|
|
64
|
+
const cache = createForegroundPreviewCache();
|
|
65
|
+
cache.remember("v1", { latestAssistantPreview: "hello", lastAgentActivityAt: 111 });
|
|
66
|
+
const status = emptyStatus();
|
|
67
|
+
assert.equal(cache.backfill("v1", status), true);
|
|
68
|
+
assert.equal(status.latestAssistantPreview, "hello");
|
|
69
|
+
assert.equal(status.lastAgentActivityAt, 111);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("an empty projection never overwrites a known non-empty value", () => {
|
|
73
|
+
const cache = createForegroundPreviewCache();
|
|
74
|
+
cache.remember("v1", { latestAssistantPreview: "hello", lastAgentActivityAt: 111 });
|
|
75
|
+
cache.remember("v1", { latestAssistantPreview: "", lastAgentActivityAt: null });
|
|
76
|
+
const status = emptyStatus();
|
|
77
|
+
assert.equal(cache.backfill("v1", status), true);
|
|
78
|
+
assert.equal(status.latestAssistantPreview, "hello");
|
|
79
|
+
assert.equal(status.lastAgentActivityAt, 111);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("a newer non-empty value overwrites an older non-empty one", () => {
|
|
83
|
+
const cache = createForegroundPreviewCache();
|
|
84
|
+
cache.remember("v1", { latestAssistantPreview: "old", lastAgentActivityAt: 111 });
|
|
85
|
+
cache.remember("v1", { latestAssistantPreview: "new", lastAgentActivityAt: 222 });
|
|
86
|
+
const status = emptyStatus();
|
|
87
|
+
cache.backfill("v1", status);
|
|
88
|
+
assert.equal(status.latestAssistantPreview, "new");
|
|
89
|
+
assert.equal(status.lastAgentActivityAt, 222);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("backfill leaves non-empty disk values untouched (disk is authoritative)", () => {
|
|
93
|
+
const cache = createForegroundPreviewCache();
|
|
94
|
+
cache.remember("v1", { latestAssistantPreview: "cached", lastAgentActivityAt: 111 });
|
|
95
|
+
const status = { latestAssistantPreview: "disk", lastAgentActivityAt: 999 };
|
|
96
|
+
assert.equal(cache.backfill("v1", status), false);
|
|
97
|
+
assert.equal(status.latestAssistantPreview, "disk");
|
|
98
|
+
assert.equal(status.lastAgentActivityAt, 999);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("backfill fills only the empty field of a mixed status", () => {
|
|
102
|
+
const cache = createForegroundPreviewCache();
|
|
103
|
+
cache.remember("v1", { latestAssistantPreview: "cached", lastAgentActivityAt: 111 });
|
|
104
|
+
const status = { latestAssistantPreview: "disk", lastAgentActivityAt: null };
|
|
105
|
+
assert.equal(cache.backfill("v1", status), true);
|
|
106
|
+
assert.equal(status.latestAssistantPreview, "disk");
|
|
107
|
+
assert.equal(status.lastAgentActivityAt, 111);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("a fully empty projection creates no entry; unknown views are no-ops", () => {
|
|
111
|
+
const cache = createForegroundPreviewCache();
|
|
112
|
+
cache.remember("v1", { latestAssistantPreview: "", lastAgentActivityAt: null });
|
|
113
|
+
assert.equal(cache.size(), 0);
|
|
114
|
+
const status = emptyStatus();
|
|
115
|
+
assert.equal(cache.backfill("v1", status), false);
|
|
116
|
+
assert.equal(cache.backfill("nope", status), false);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("views are independent; forget and clear remove entries", () => {
|
|
120
|
+
const cache = createForegroundPreviewCache();
|
|
121
|
+
cache.remember("v1", { latestAssistantPreview: "one", lastAgentActivityAt: 1 });
|
|
122
|
+
cache.remember("v2", { latestAssistantPreview: "two", lastAgentActivityAt: 2 });
|
|
123
|
+
assert.equal(cache.size(), 2);
|
|
124
|
+
|
|
125
|
+
assert.equal(cache.forget("v1"), true);
|
|
126
|
+
assert.equal(cache.forget("v1"), false);
|
|
127
|
+
assert.equal(cache.size(), 1);
|
|
128
|
+
const status = emptyStatus();
|
|
129
|
+
assert.equal(cache.backfill("v1", status), false);
|
|
130
|
+
assert.equal(cache.backfill("v2", status), true);
|
|
131
|
+
|
|
132
|
+
cache.clear();
|
|
133
|
+
assert.equal(cache.size(), 0);
|
|
134
|
+
assert.equal(cache.backfill("v2", emptyStatus()), false);
|
|
135
|
+
});
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
- [x] **Step 2: 运行测试确认失败**
|
|
139
|
+
|
|
140
|
+
Run: `cd $WT && node --test test/foreground-preview-cache.test.mjs`
|
|
141
|
+
Expected: FAIL — `Cannot find module '../src/core/foreground-preview-cache.mjs'`
|
|
142
|
+
|
|
143
|
+
- [x] **Step 3: 写最小实现**
|
|
144
|
+
|
|
145
|
+
创建 `$WT/src/core/foreground-preview-cache.mjs`:
|
|
146
|
+
|
|
147
|
+
```js
|
|
148
|
+
/**
|
|
149
|
+
* Process-local read-your-writes cache for foreground state projections
|
|
150
|
+
* (issue #113).
|
|
151
|
+
*
|
|
152
|
+
* Foreground turns are mirrored into state.json through the detached View
|
|
153
|
+
* State Coordinator: `message_end` sets `latestAssistantPreview` /
|
|
154
|
+
* `lastAgentActivityAt` and fires a fire-and-forget `sync_foreground` command;
|
|
155
|
+
* the coordinator journals + fsyncs before materializing the file. The next
|
|
156
|
+
* event (`agent_end`, ~7ms later) rebuilds its in-memory status from the
|
|
157
|
+
* still-stale state.json, derives the "Needs instructions" fallback summary and
|
|
158
|
+
* overwrites the fresher projection that was still in flight.
|
|
159
|
+
*
|
|
160
|
+
* This cache restores read-your-writes for the two fields whose only legitimate
|
|
161
|
+
* transitions are "empty → non-empty" and "old non-empty → new non-empty":
|
|
162
|
+
* a non-empty value is always remembered, an empty value never overwrites a
|
|
163
|
+
* known non-empty one, and a disk rebuild backfills only empty fields (the
|
|
164
|
+
* materialized file stays authoritative whenever it has a value).
|
|
165
|
+
*
|
|
166
|
+
* Module-level by necessity: `serviceFor()` creates a new service instance per
|
|
167
|
+
* call (src/index.ts), so a per-instance cache would be discarded between
|
|
168
|
+
* events.
|
|
169
|
+
*/
|
|
170
|
+
|
|
171
|
+
/** @typedef {{ latestAssistantPreview: string, lastAgentActivityAt: number|null }} KnownForegroundFields */
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* @returns {{
|
|
175
|
+
* remember: (viewId: string, projection: { latestAssistantPreview?: unknown, lastAgentActivityAt?: unknown }) => void,
|
|
176
|
+
* backfill: (viewId: string, status: { latestAssistantPreview?: unknown, lastAgentActivityAt?: unknown }) => boolean,
|
|
177
|
+
* forget: (viewId: string) => boolean,
|
|
178
|
+
* clear: () => void,
|
|
179
|
+
* size: () => number,
|
|
180
|
+
* }}
|
|
181
|
+
*/
|
|
182
|
+
export function createForegroundPreviewCache() {
|
|
183
|
+
/** @type {Map<string, KnownForegroundFields>} */
|
|
184
|
+
const known = new Map();
|
|
185
|
+
|
|
186
|
+
function remember(viewId, projection) {
|
|
187
|
+
if (!viewId || !projection) return;
|
|
188
|
+
const preview = projection.latestAssistantPreview;
|
|
189
|
+
const activityAt = projection.lastAgentActivityAt;
|
|
190
|
+
const hasPreview = typeof preview === "string" && preview.length > 0;
|
|
191
|
+
const hasActivity = activityAt != null;
|
|
192
|
+
if (!hasPreview && !hasActivity) return;
|
|
193
|
+
const entry = known.get(viewId) ?? { latestAssistantPreview: "", lastAgentActivityAt: null };
|
|
194
|
+
if (hasPreview) entry.latestAssistantPreview = preview;
|
|
195
|
+
if (hasActivity) entry.lastAgentActivityAt = activityAt;
|
|
196
|
+
known.set(viewId, entry);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function backfill(viewId, status) {
|
|
200
|
+
if (!viewId || !status) return false;
|
|
201
|
+
const entry = known.get(viewId);
|
|
202
|
+
if (!entry) return false;
|
|
203
|
+
let changed = false;
|
|
204
|
+
if (entry.latestAssistantPreview && !status.latestAssistantPreview) {
|
|
205
|
+
status.latestAssistantPreview = entry.latestAssistantPreview;
|
|
206
|
+
changed = true;
|
|
207
|
+
}
|
|
208
|
+
if (entry.lastAgentActivityAt != null && status.lastAgentActivityAt == null) {
|
|
209
|
+
status.lastAgentActivityAt = entry.lastAgentActivityAt;
|
|
210
|
+
changed = true;
|
|
211
|
+
}
|
|
212
|
+
return changed;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function forget(viewId) {
|
|
216
|
+
return known.delete(viewId);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function clear() {
|
|
220
|
+
known.clear();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function size() {
|
|
224
|
+
return known.size;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return { remember, backfill, forget, clear, size };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Shared cache used by the runtime service (module-level: see module doc). */
|
|
231
|
+
export const foregroundPreviewCache = createForegroundPreviewCache();
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
- [x] **Step 4: 运行测试确认通过**
|
|
235
|
+
|
|
236
|
+
Run: `cd $WT && node --test test/foreground-preview-cache.test.mjs`
|
|
237
|
+
Expected: PASS(7 tests)
|
|
238
|
+
|
|
239
|
+
- [x] **Step 5: typecheck**
|
|
240
|
+
|
|
241
|
+
Run: `cd $WT && npm run typecheck`
|
|
242
|
+
Expected: 无错误(若 JSDoc 报错,修正类型注释而非跳过)
|
|
243
|
+
|
|
244
|
+
- [x] **Step 6: Commit**
|
|
245
|
+
|
|
246
|
+
```bash
|
|
247
|
+
cd $WT
|
|
248
|
+
git -C $WT add src/core/foreground-preview-cache.mjs test/foreground-preview-cache.test.mjs
|
|
249
|
+
git -C $WT commit -m "feat(core): add foreground preview read-your-writes cache (issue #113)"
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
---
|
|
253
|
+
|
|
254
|
+
### Task 2: service 接线 + 竞态回归测试(含测试隔离)
|
|
255
|
+
|
|
256
|
+
**Files:**
|
|
257
|
+
- Modify: `$WT/src/runtime/service.mjs`(import 段;`writeForegroundState`;`syncRowEvent`)
|
|
258
|
+
- Test: `$WT/test/service.test.mjs`(import 段;顶部加 `beforeEach` 与 fake helper;新增竞态用例)
|
|
259
|
+
|
|
260
|
+
**Interfaces:**
|
|
261
|
+
- Consumes: Task 1 的 `foregroundPreviewCache`(`remember` / `backfill` / `clear`)。
|
|
262
|
+
- Produces: `writeForegroundState` 每次投影后写缓存;`syncRowEvent` 每次磁盘重建后回填。测试 helper `delayedMaterializingSendStateCommand(root, opts)`。
|
|
263
|
+
|
|
264
|
+
- [x] **Step 1: 写失败测试(红证)**
|
|
265
|
+
|
|
266
|
+
在 `$WT/test/service.test.mjs` 中:
|
|
267
|
+
|
|
268
|
+
(a) 修改 `node:test` import 行,加入 `beforeEach`:
|
|
269
|
+
|
|
270
|
+
```js
|
|
271
|
+
import { test, beforeEach } from "node:test";
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
(b) 在 `import { createService, shouldProbePtySupport } from "../src/runtime/service.mjs";` 之后新增:
|
|
275
|
+
|
|
276
|
+
```js
|
|
277
|
+
import { foregroundPreviewCache } from "../src/core/foreground-preview-cache.mjs";
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
(c) 在 `startTrackedCoordinator` helper 之后新增两个 helper:
|
|
281
|
+
|
|
282
|
+
```js
|
|
283
|
+
/** Reset the module-level preview cache between tests (issue #113). */
|
|
284
|
+
beforeEach(() => {
|
|
285
|
+
foregroundPreviewCache.clear();
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Issue 113 fixture: a sendStateCommand stand-in that materializes
|
|
290
|
+
* sync_foreground projections after a delay — the coordinator's fsync +
|
|
291
|
+
* socket latency in miniature. Commands land in arrival order, exactly like
|
|
292
|
+
* the real single writer. Non-sync kinds resolve a decided rejection.
|
|
293
|
+
*/
|
|
294
|
+
function delayedMaterializingSendStateCommand(root, { delayMs = 20 } = {}) {
|
|
295
|
+
const applied = [];
|
|
296
|
+
return {
|
|
297
|
+
applied,
|
|
298
|
+
send: (targetRoot, command) =>
|
|
299
|
+
new Promise((resolve) => {
|
|
300
|
+
if (command.kind !== "sync_foreground") {
|
|
301
|
+
resolve({ status: "rejected", reason: "no_change", materializedRevision: 0 });
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
setTimeout(() => {
|
|
305
|
+
const projection = command.payload?.projection ?? {};
|
|
306
|
+
const current = readState(root, command.viewId) ?? {};
|
|
307
|
+
writeState(root, { ...current, ...projection });
|
|
308
|
+
applied.push(command);
|
|
309
|
+
resolve({ status: "applied", reason: command.kind, materializedRevision: applied.length });
|
|
310
|
+
}, delayMs);
|
|
311
|
+
}),
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
(d) 在既有用例 `test("syncForegroundEvent auto-completes foreground turn when auto-done flag is off", ...)` 之后新增竞态用例:
|
|
317
|
+
|
|
318
|
+
```js
|
|
319
|
+
test("issue 113: agent_end rebuild cannot clobber the in-flight message_end preview", async () => {
|
|
320
|
+
const root = freshRoot();
|
|
321
|
+
const prevCoordinator = setEnv("AGENT_BOARD_COORDINATOR", undefined);
|
|
322
|
+
try {
|
|
323
|
+
const meta = createView(root, { id: "v1", name: "a", cwd: "/r" });
|
|
324
|
+
const fake = delayedMaterializingSendStateCommand(root, { delayMs: 20 });
|
|
325
|
+
const text = "All done with the long task.";
|
|
326
|
+
// Mirror production: serviceFor() builds a fresh service per event, so the
|
|
327
|
+
// cache must survive across instances.
|
|
328
|
+
await service(root, { sendStateCommand: fake.send }).syncForegroundEvent(meta.sessionFile, { type: "agent_start" });
|
|
329
|
+
await service(root, { sendStateCommand: fake.send }).syncForegroundEvent(meta.sessionFile, {
|
|
330
|
+
type: "message_end",
|
|
331
|
+
message: { role: "assistant", stopReason: "stop", content: [{ type: "text", text }] },
|
|
332
|
+
});
|
|
333
|
+
// agent_end reads state.json synchronously while the message_end write is
|
|
334
|
+
// still delayed — the exact 0.7.0 race window.
|
|
335
|
+
await service(root, { sendStateCommand: fake.send }).syncForegroundEvent(meta.sessionFile, { type: "agent_end" });
|
|
336
|
+
|
|
337
|
+
const final = await waitFor(() => {
|
|
338
|
+
const s = readState(root, "v1");
|
|
339
|
+
return s?.semanticState === "idle" && s?.latestAssistantPreview === text ? s : null;
|
|
340
|
+
}, 3000);
|
|
341
|
+
assert.ok(final, "the last materialized projection keeps the assistant preview");
|
|
342
|
+
assert.equal(final.summary, text);
|
|
343
|
+
assert.equal(final.lastAgentActivityAt != null, true);
|
|
344
|
+
} finally {
|
|
345
|
+
setEnv("AGENT_BOARD_COORDINATOR", prevCoordinator);
|
|
346
|
+
rmSync(root, { recursive: true, force: true });
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
- [x] **Step 2: 运行测试确认失败**
|
|
352
|
+
|
|
353
|
+
Run: `cd $WT && node --test --test-name-pattern "issue 113: agent_end rebuild" test/service.test.mjs`
|
|
354
|
+
Expected: FAIL — `the last materialized projection keeps the assistant preview`(磁盘最终 preview 为空、summary 为 "Needs instructions")。这就是 bug 的确定性复现。
|
|
355
|
+
|
|
356
|
+
- [x] **Step 3: 接线实现**
|
|
357
|
+
|
|
358
|
+
在 `$WT/src/runtime/service.mjs`:
|
|
359
|
+
|
|
360
|
+
(a) import 段(`follow-up-queue.mjs` import 之后)新增:
|
|
361
|
+
|
|
362
|
+
```js
|
|
363
|
+
import { foregroundPreviewCache } from "../core/foreground-preview-cache.mjs";
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
(b) `writeForegroundState` 中,`projected.currentRunId = null;` 之后、`if (coordinatorDisabled())` 之前新增:
|
|
367
|
+
|
|
368
|
+
```js
|
|
369
|
+
foregroundPreviewCache.remember(row.meta.id, projected);
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
(c) `syncRowEvent` 中,`const status = statusFromRow(row);` 之后新增:
|
|
373
|
+
|
|
374
|
+
```js
|
|
375
|
+
foregroundPreviewCache.backfill(row.meta.id, status);
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
- [x] **Step 4: 运行新测试与既有前台回归**
|
|
379
|
+
|
|
380
|
+
Run: `cd $WT && node --test --test-name-pattern "issue 113: agent_end rebuild" test/service.test.mjs`
|
|
381
|
+
Expected: PASS
|
|
382
|
+
|
|
383
|
+
Run: `cd $WT && node --test --test-name-pattern "syncForegroundEvent" test/service.test.mjs`
|
|
384
|
+
Expected: 全部 PASS(A5:直写模式与既有行为不回归)
|
|
385
|
+
|
|
386
|
+
- [x] **Step 5: Commit**
|
|
387
|
+
|
|
388
|
+
```bash
|
|
389
|
+
cd $WT
|
|
390
|
+
git -C $WT add src/runtime/service.mjs test/service.test.mjs
|
|
391
|
+
git -C $WT commit -m "fix(service): keep foreground preview across coordinator write lag (issue #113)"
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
---
|
|
395
|
+
|
|
396
|
+
### Task 3: 归档清理(两个真实站点)
|
|
397
|
+
|
|
398
|
+
**Files:**
|
|
399
|
+
- Modify: `$WT/src/runtime/service.mjs`(`archiveView`、`archiveByState`)
|
|
400
|
+
- Test: `$WT/test/service.test.mjs`(新增两个用例)
|
|
401
|
+
|
|
402
|
+
**Interfaces:**
|
|
403
|
+
- Consumes: Task 1 的 `foregroundPreviewCache.forget`。
|
|
404
|
+
- Produces: 归档后缓存无该 viewId 条目(`archive` / `archiveMany`→`archiveView`、`archiveByState` 两条路径)。
|
|
405
|
+
|
|
406
|
+
- [x] **Step 1: 写失败测试**
|
|
407
|
+
|
|
408
|
+
在 `$WT/test/service.test.mjs` 新增(紧邻 Task 2 的用例之后):
|
|
409
|
+
|
|
410
|
+
```js
|
|
411
|
+
test("issue 113: archiving a row evicts its preview cache entry", async () => {
|
|
412
|
+
const root = freshRoot();
|
|
413
|
+
try {
|
|
414
|
+
createView(root, { id: "v1", name: "a", cwd: "/r" });
|
|
415
|
+
createView(root, { id: "v2", name: "b", cwd: "/r" });
|
|
416
|
+
foregroundPreviewCache.remember("v1", { latestAssistantPreview: "one", lastAgentActivityAt: 1 });
|
|
417
|
+
foregroundPreviewCache.remember("v2", { latestAssistantPreview: "two", lastAgentActivityAt: 2 });
|
|
418
|
+
|
|
419
|
+
const res = await service(root).archive("v1");
|
|
420
|
+
assert.equal(res.ok, true);
|
|
421
|
+
assert.equal(foregroundPreviewCache.size(), 1, "only the archived view's entry is evicted");
|
|
422
|
+
assert.equal(foregroundPreviewCache.backfill("v1", { latestAssistantPreview: "", lastAgentActivityAt: null }), false);
|
|
423
|
+
assert.equal(foregroundPreviewCache.backfill("v2", { latestAssistantPreview: "", lastAgentActivityAt: null }), true);
|
|
424
|
+
} finally {
|
|
425
|
+
rmSync(root, { recursive: true, force: true });
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
test("issue 113: archiveByState evicts preview cache entries for archived rows", () => {
|
|
430
|
+
const root = freshRoot();
|
|
431
|
+
try {
|
|
432
|
+
createView(root, { id: "v1", name: "a", cwd: "/r" });
|
|
433
|
+
createView(root, { id: "v2", name: "b", cwd: "/r" });
|
|
434
|
+
foregroundPreviewCache.remember("v1", { latestAssistantPreview: "one", lastAgentActivityAt: 1 });
|
|
435
|
+
foregroundPreviewCache.remember("v2", { latestAssistantPreview: "two", lastAgentActivityAt: 2 });
|
|
436
|
+
const s = readState(root, "v1");
|
|
437
|
+
s.semanticState = "completed";
|
|
438
|
+
s.processState = "exited";
|
|
439
|
+
writeState(root, s);
|
|
440
|
+
|
|
441
|
+
const res = service(root).archiveByState("completed");
|
|
442
|
+
assert.equal(res.archived, 1);
|
|
443
|
+
assert.equal(foregroundPreviewCache.size(), 1);
|
|
444
|
+
} finally {
|
|
445
|
+
rmSync(root, { recursive: true, force: true });
|
|
446
|
+
}
|
|
447
|
+
});
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
- [x] **Step 2: 运行测试确认失败**
|
|
451
|
+
|
|
452
|
+
Run: `cd $WT && node --test --test-name-pattern "evicts" test/service.test.mjs`
|
|
453
|
+
Expected: FAIL — `expected 1 to be 2`(缓存未清理)
|
|
454
|
+
|
|
455
|
+
- [x] **Step 3: 实现 forget**
|
|
456
|
+
|
|
457
|
+
在 `$WT/src/runtime/service.mjs`:
|
|
458
|
+
|
|
459
|
+
(a) `archiveView` 中,`row.meta.archived = true;` 之前新增:
|
|
460
|
+
|
|
461
|
+
```js
|
|
462
|
+
foregroundPreviewCache.forget(viewId);
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
(b) `archiveByState` 循环中,`row.meta.archived = true;` 之前新增:
|
|
466
|
+
|
|
467
|
+
```js
|
|
468
|
+
foregroundPreviewCache.forget(row.meta.id);
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
- [x] **Step 4: 运行测试确认通过**
|
|
472
|
+
|
|
473
|
+
Run: `cd $WT && node --test --test-name-pattern "evicts" test/service.test.mjs`
|
|
474
|
+
Expected: PASS(2 tests)
|
|
475
|
+
|
|
476
|
+
- [x] **Step 5: Commit**
|
|
477
|
+
|
|
478
|
+
```bash
|
|
479
|
+
cd $WT
|
|
480
|
+
git -C $WT add src/runtime/service.mjs test/service.test.mjs
|
|
481
|
+
git -C $WT commit -m "fix(service): evict preview cache on archive (issue #113)"
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
---
|
|
485
|
+
|
|
486
|
+
### Task 4: 真实 coordinator 不变式测试
|
|
487
|
+
|
|
488
|
+
**Files:**
|
|
489
|
+
- Test: `$WT/test/service.test.mjs`(新增用例)
|
|
490
|
+
- 复用: `startTrackedCoordinator` / `waitFor`(同文件顶部),`test-support/ensure-coordinator-helper.mjs`
|
|
491
|
+
|
|
492
|
+
**Interfaces:**
|
|
493
|
+
- Consumes: Task 2 的 service 接线。
|
|
494
|
+
- Produces: 真实 coordinator(fsync + socket 往返)下的不变式验证——任何时序下最终状态 preview 必须保留。
|
|
495
|
+
|
|
496
|
+
- [x] **Step 1: 写测试**
|
|
497
|
+
|
|
498
|
+
在 `$WT/test/service.test.mjs` 新增(紧邻 Task 3 的用例之后):
|
|
499
|
+
|
|
500
|
+
```js
|
|
501
|
+
test("issue 113: real coordinator keeps the assistant preview through a foreground turn", async () => {
|
|
502
|
+
const root = freshRoot();
|
|
503
|
+
const { coord, restore } = await startTrackedCoordinator(root);
|
|
504
|
+
try {
|
|
505
|
+
const meta = createView(root, { id: "v1", name: "a", cwd: "/r" });
|
|
506
|
+
const text = "Coordinator round trip keeps this preview.";
|
|
507
|
+
await service(root).syncForegroundEvent(meta.sessionFile, { type: "agent_start" });
|
|
508
|
+
await service(root).syncForegroundEvent(meta.sessionFile, {
|
|
509
|
+
type: "message_end",
|
|
510
|
+
message: { role: "assistant", stopReason: "stop", content: [{ type: "text", text }] },
|
|
511
|
+
});
|
|
512
|
+
await service(root).syncForegroundEvent(meta.sessionFile, { type: "agent_end" });
|
|
513
|
+
|
|
514
|
+
const final = await waitFor(() => {
|
|
515
|
+
const s = readState(root, "v1");
|
|
516
|
+
return s?.latestAssistantPreview === text ? s : null;
|
|
517
|
+
});
|
|
518
|
+
assert.ok(final, "preview survives the real coordinator round trip");
|
|
519
|
+
assert.notEqual(final.summary, "Needs instructions");
|
|
520
|
+
assert.equal(final.lastAgentActivityAt != null, true);
|
|
521
|
+
// Let in-flight fire-and-forget beats settle against the tracked
|
|
522
|
+
// coordinator before kill (see the input-mirror test above).
|
|
523
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
524
|
+
} finally {
|
|
525
|
+
await coord.kill();
|
|
526
|
+
restore();
|
|
527
|
+
rmSync(root, { recursive: true, force: true });
|
|
528
|
+
}
|
|
529
|
+
});
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
- [x] **Step 2: 运行测试确认通过**
|
|
533
|
+
|
|
534
|
+
Run: `cd $WT && node --test --test-name-pattern "real coordinator keeps the assistant preview" test/service.test.mjs`
|
|
535
|
+
Expected: PASS
|
|
536
|
+
|
|
537
|
+
- [x] **Step 3: 红/绿自证(可选但推荐)**
|
|
538
|
+
|
|
539
|
+
临时注释掉 Task 2 Step 3(c) 的 `backfill` 行 → 运行该用例与 A3 用例,确认二者失败 → 恢复该行 → 再次确认通过。**不要提交注释状态**。
|
|
540
|
+
|
|
541
|
+
- [x] **Step 4: Commit**
|
|
542
|
+
|
|
543
|
+
```bash
|
|
544
|
+
cd $WT
|
|
545
|
+
git -C $WT add test/service.test.mjs
|
|
546
|
+
git -C $WT commit -m "test(service): real-coordinator preview invariant (issue #113)"
|
|
547
|
+
```
|
|
548
|
+
|
|
549
|
+
---
|
|
550
|
+
|
|
551
|
+
### Task 5: 全量回归与验收对账(含用户实测清单)
|
|
552
|
+
|
|
553
|
+
**Files:**
|
|
554
|
+
- 无生产代码改动(除非回归暴露问题,那就回到对应 Task 修复)
|
|
555
|
+
|
|
556
|
+
**Interfaces:**
|
|
557
|
+
- Consumes: Task 1–4 的全部改动。
|
|
558
|
+
- Produces: 全量绿 + 逐项验收证据;U1 用户实测清单。
|
|
559
|
+
|
|
560
|
+
- [x] **Step 1: 全量回归**
|
|
561
|
+
|
|
562
|
+
Run: `cd $WT && npm run typecheck && npm test`
|
|
563
|
+
Expected: 全绿(A8)。若有失败,回到对应 Task 修复后重跑。
|
|
564
|
+
|
|
565
|
+
- [x] **Step 2: 逐项验收对账**
|
|
566
|
+
|
|
567
|
+
按 spec 验收矩阵逐条记录实际执行的命令与结果:
|
|
568
|
+
|
|
569
|
+
| ID | 命令 | 结果 |
|
|
570
|
+
|---|---|---|
|
|
571
|
+
| A1 / A2 | `node --test test/foreground-preview-cache.test.mjs` | PASS(记录数量) |
|
|
572
|
+
| A3 | `node --test --test-name-pattern "issue 113" test/service.test.mjs` | PASS(含移除缓存时失败的已验证红证) |
|
|
573
|
+
| A4 | 同上(真实 coordinator 用例) | PASS |
|
|
574
|
+
| A5 | 既有 `syncForegroundEvent*` 用例 | PASS |
|
|
575
|
+
| A6 | 归档两用例 | PASS |
|
|
576
|
+
| A7 | `beforeEach` 清缓存已落地(service.test.mjs) | 已落地 |
|
|
577
|
+
| A8 | `npm run typecheck && npm test` | PASS |
|
|
578
|
+
|
|
579
|
+
- [x] **Step 3: 记录 U1 用户实测清单(交给用户执行)**
|
|
580
|
+
|
|
581
|
+
实操步骤与通过标准:
|
|
582
|
+
|
|
583
|
+
1. 将修复安装到实际运行的扩展(或从 worktree 链接/安装),**重启 pi 会话**(进程内缓存只对新代码加载后的事件生效)。
|
|
584
|
+
2. 在一个交互式(前台)会话里完成一轮带 assistant 文本回复的对话,等待该行进入 idle。
|
|
585
|
+
3. 观察 dashboard 行摘要:应显示 assistant 最后回复的首句;不应为 "Needs instructions"。
|
|
586
|
+
4. 可选核对:`~/.pi/.../views/<viewId>/state-journal.jsonl` 中该 run 的 `sync_foreground` 序列里,最终一条的 `projection.latestAssistantPreview` 非空。
|
|
587
|
+
5. **前提**:修复前已被写坏的行(磁盘 preview 为空)不会追溯治愈;需等该会话下一轮 `message_end` 才会恢复正常显示。
|
|
588
|
+
|
|
589
|
+
- [x] **Step 4: 交付说明(写入 issue 评论草稿,供 PR 阶段使用)**
|
|
590
|
+
|
|
591
|
+
内容包含:修复机制一句话、A1–A8 实测证据、U1 前提与清单、遗留边界(见 spec 非目标)。
|
|
592
|
+
|
|
593
|
+
- [x] **Step 5: Commit(如有对账文档产物则提交,否则本 task 无 commit)**
|
|
594
|
+
|
|
595
|
+
```bash
|
|
596
|
+
cd $WT
|
|
597
|
+
git -C $WT status --short
|
|
598
|
+
# 若产生文档改动(例如 plan 勾选状态):
|
|
599
|
+
git -C $WT add docs/superpowers/plans/2026-09-14-issue-113-foreground-preview-race.md
|
|
600
|
+
git -C $WT commit -m "docs(plan): mark issue 113 acceptance reconciliation"
|
|
601
|
+
```
|
|
602
|
+
|
|
603
|
+
---
|
|
604
|
+
|
|
605
|
+
## Self-Review 记录(写完计划后自查)
|
|
606
|
+
|
|
607
|
+
- **Spec 覆盖**:A1–A8、U1 全部映射到 Task 1–5(见顶部映射表);spec 的"模块级单例""测试隔离""两个归档站点""延迟落盘 fake""真实 coordinator 不变式"分别落到 Task 1/2/3/4。
|
|
608
|
+
- **占位符扫描**:无 TBD/TODO;所有测试与实现代码均为可执行内容;断言基于已核实的行为(`deriveSummary` 对单句文本返回全文,`applyAutoStateToStatus` 之后 summary 仍为首句)。
|
|
609
|
+
- **类型一致性**:`createForegroundPreviewCache` 的 `remember/backfill/forget/clear/size` 在 Task 1 定义,Task 2/3 与测试使用的名称与签名一致;测试 helper `delayedMaterializingSendStateCommand` 在 Task 2 定义并在 Task 2 用例中使用(Task 4 不用它)。
|