@zhuxixi/pi-agent-board 0.5.2 → 0.6.1
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 +44 -0
- package/README.md +43 -5
- package/docs/superpowers/plans/2026-09-03-code-refs-pr-backlink-narrow.md +551 -0
- package/docs/superpowers/plans/2026-09-04-evidence-outputpreview.md +209 -0
- package/docs/superpowers/plans/2026-09-04-warm-host-reclaim.md +796 -0
- package/docs/superpowers/plans/2026-09-05-issue-13-drainnextfollowup-pty-probe.md +114 -0
- package/docs/superpowers/plans/2026-09-05-issue-38-windows-wezterm-ime-cursor.md +73 -0
- package/docs/superpowers/plans/2026-09-05-issue-39-truncate-codepoint-boundary.md +143 -0
- package/docs/superpowers/plans/2026-09-05-issue-61-mention-fallback-guards.md +226 -0
- package/docs/superpowers/plans/2026-09-05-issue-63-flaky-manual-completion.md +87 -0
- package/docs/superpowers/plans/2026-09-05-issue-64-changelog-release-helper.md +53 -0
- package/docs/superpowers/plans/2026-09-05-pty-host-stacking-sock-race.md +731 -0
- package/docs/superpowers/plans/2026-09-08-attach-ctrl-left-detach.md +30 -0
- package/docs/superpowers/plans/2026-09-08-dashboard-shrink-repaint.md +68 -0
- package/docs/superpowers/plans/2026-09-08-legacy-stale-host-recovery.md +125 -0
- package/docs/superpowers/plans/2026-09-08-spawn-async-error-swallow.md +56 -0
- package/docs/superpowers/plans/2026-09-08-stale-model-attach-guard.md +96 -0
- package/docs/superpowers/specs/2026-08-29-code-refs-badges-design.md +1 -1
- package/docs/superpowers/specs/2026-09-03-code-refs-pr-backlink-narrow-design.md +92 -0
- package/docs/superpowers/specs/2026-09-04-evidence-outputpreview-design.md +50 -0
- package/docs/superpowers/specs/2026-09-04-warm-host-reclaim-design.md +106 -0
- package/docs/superpowers/specs/2026-09-05-issue-13-drainnextfollowup-pty-probe-design.md +64 -0
- package/docs/superpowers/specs/2026-09-05-issue-38-windows-wezterm-ime-design.md +48 -0
- package/docs/superpowers/specs/2026-09-05-issue-39-truncate-codepoint-boundary-design.md +64 -0
- package/docs/superpowers/specs/2026-09-05-issue-61-mention-fallback-design.md +71 -0
- package/docs/superpowers/specs/2026-09-05-issue-63-flaky-manual-completion-design.md +49 -0
- package/docs/superpowers/specs/2026-09-05-issue-64-changelog-helper-design.md +76 -0
- package/docs/superpowers/specs/2026-09-05-pty-host-stacking-sock-race-design.md +510 -0
- package/docs/superpowers/specs/2026-09-08-attach-ctrl-left-detach-design.md +58 -0
- package/docs/superpowers/specs/2026-09-08-dashboard-shrink-repaint-design.md +52 -0
- package/docs/superpowers/specs/2026-09-08-legacy-stale-host-recovery-design.md +87 -0
- package/docs/superpowers/specs/2026-09-08-spawn-async-error-swallow-design.md +56 -0
- package/docs/superpowers/specs/2026-09-08-stale-model-attach-guard-design.md +79 -0
- package/package.json +83 -81
- package/runner/job-runner.mjs +2 -2
- package/runner/pty-runner.mjs +626 -3
- package/runner/state-runner.mjs +3 -0
- package/runner/title-runner.mjs +1 -1
- package/scripts/release_helper.mjs +277 -0
- package/src/commands/agent-board.ts +47 -35
- package/src/commands/attach-decision.mjs +66 -0
- package/src/commands/attach-flow.ts +45 -39
- package/src/commands/bg.ts +9 -0
- package/src/core/code-refs.mjs +85 -33
- package/src/core/evidence.mjs +2 -2
- package/src/core/heuristics.mjs +75 -2
- package/src/core/host-coordination.mjs +182 -0
- package/src/core/host-crash.mjs +43 -3
- package/src/core/host-probe.mjs +196 -0
- package/src/core/launch-options.mjs +17 -0
- package/src/core/launch.mjs +35 -35
- package/src/core/locks.mjs +196 -1
- package/src/core/paths.mjs +24 -0
- package/src/core/store.mjs +164 -5
- package/src/core/types.mjs +17 -1
- package/src/core/warm-host-sweeper.mjs +150 -0
- package/src/index.ts +40 -3
- package/src/runtime/service.mjs +1071 -108
- package/src/ui/dashboard-decisions.mjs +55 -0
- package/src/ui/dashboard.ts +67 -13
- package/src/ui/pty-attach.ts +23 -5
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# issue #89 plan:Ctrl+← detach 和弦实现
|
|
2
|
+
|
|
3
|
+
日期:2026-09-08 · spec:docs/superpowers/specs/2026-09-08-attach-ctrl-left-detach-design.md
|
|
4
|
+
|
|
5
|
+
## 任务拆解
|
|
6
|
+
|
|
7
|
+
### T1:Ctrl+← 分支 + header 文案(A1, A2, A4)
|
|
8
|
+
|
|
9
|
+
文件:`src/ui/pty-attach.ts`
|
|
10
|
+
|
|
11
|
+
1. `handleInput` 在 `Key.left` 分支(~L247)之前插入 Ctrl+← 分支(spec D1 代码原样,含注释)。确认 `Key.ctrl` 已从 pi-tui import(文件顶部现有 `Key` import——matchesKey/Key 都在用,无需新 import)。
|
|
12
|
+
2. header 两处文案(~L282 `← detach`、~L298 `← to detach`)→ `←/Ctrl+← detach` / `←/Ctrl+← to detach`。
|
|
13
|
+
|
|
14
|
+
### T2:测试扩建(A1-A4)
|
|
15
|
+
|
|
16
|
+
文件:`test-support/detach-gate-smoke.ts` + `test/pty-attach-detach-gate.test.mjs`
|
|
17
|
+
|
|
18
|
+
1. harness 加场景(照现有场景范式:makeAttach + writeToTerm + handleInput 注入):
|
|
19
|
+
- **ctrlLeftDetachesOnDraft**:造一个 editor_state=draft(或写非空内容行进 buffer——看现有 `leftEditorStateBlocksDetachOnDraft` 场景怎么造 draft)→ `handleInput("\x1b[1;5D")` → didDetach() === true;
|
|
20
|
+
- **ctrlLeftDetachesOnEmptyInput**:空编辑器场景(照现有 C 场景)→ 同序列 → didDetach() === true;
|
|
21
|
+
- **headerMentionsCtrlLeft**:`attach.render(80)` 输出行里含 "Ctrl+←"(header 在 render 输出组装;注意 attaching 状态——renderLoading 的 L298 在 loading 时显示,L282 在正常帧。两个都改后任一断言即可,取正常帧:先 writeToTerm 一点输出让组件脱离 loading)。
|
|
22
|
+
2. 测试文件加 3 条断言(照现有断言风格)。
|
|
23
|
+
|
|
24
|
+
### T3:全量回归(A5)
|
|
25
|
+
|
|
26
|
+
`npm test`(基线 618)+ `npm run typecheck`。
|
|
27
|
+
|
|
28
|
+
## 验收对账
|
|
29
|
+
- A1/A2/A4 → T2 · A3 → 现有断言回归 · A5 → T3
|
|
30
|
+
- U1(用户实测,合并后):attach 活跃 session 输入文字后 Ctrl+← 退出。
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# issue #88 plan:dashboard 花屏修复实现
|
|
2
|
+
|
|
3
|
+
日期:2026-09-08 · spec:docs/superpowers/specs/2026-09-08-dashboard-shrink-repaint-design.md
|
|
4
|
+
|
|
5
|
+
## 任务拆解
|
|
6
|
+
|
|
7
|
+
### T1:dashboard.ts render 包装(A1, A2, A3)
|
|
8
|
+
|
|
9
|
+
文件:`src/ui/dashboard.ts`(唯一运行时代码改动文件,.ts 层满足 jiti 可重载约束)
|
|
10
|
+
|
|
11
|
+
1. 组件新增字段(class 字段区,~L126-147 附近):
|
|
12
|
+
```ts
|
|
13
|
+
/** First frame after mount must clear the screen: pi-tui's first render
|
|
14
|
+
* "assumes clean screen" (fullRender(false)) and overlays never get
|
|
15
|
+
* clearOnShrink — crash output / dirty bottoms would persist (issue #88). */
|
|
16
|
+
private needsFullClear = true;
|
|
17
|
+
/** Content line count BEFORE fitToHeight padding (padding always fills the
|
|
18
|
+
* terminal height, so padded counts never shrink — shrink must be detected
|
|
19
|
+
* on content lines). */
|
|
20
|
+
private lastContentLineCount: number | null = null;
|
|
21
|
+
private frameContentLineCount = 0;
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
2. `fitToHeight` 开头记录 pad 前行数:`this.frameContentLineCount = lines.length;`
|
|
25
|
+
|
|
26
|
+
3. 现有 `render(width: number): string[]`(L1158 起)整体改名为 `private renderLines(width: number): string[]`,新增包装:
|
|
27
|
+
```ts
|
|
28
|
+
render(width: number): string[] {
|
|
29
|
+
const lines = this.renderLines(width);
|
|
30
|
+
// Self-heal frames (issue #88): pi-tui disables clearOnShrink under overlays,
|
|
31
|
+
// so a content shrink would leave stale rows forever. Force a full clear on
|
|
32
|
+
// the first frame and on any content-line shrink. requestRender(true) is
|
|
33
|
+
// nextTick-async — safe to call from inside render.
|
|
34
|
+
if (this.needsFullClear || (this.lastContentLineCount != null && this.frameContentLineCount < this.lastContentLineCount)) {
|
|
35
|
+
this.needsFullClear = false;
|
|
36
|
+
this.tui.requestRender(true);
|
|
37
|
+
}
|
|
38
|
+
this.lastContentLineCount = this.frameContentLineCount;
|
|
39
|
+
return lines;
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
注意点:
|
|
44
|
+
- `renderLines` 保持原签名与所有 return 分支不变(仅改名);
|
|
45
|
+
- `this.tui.requestRender` 在 Component 的 TUI 类型上存在(pi-tui 导出类型含它;若类型缺 force 参数签名,用 `(this.tui as any).requestRender(true)` 并在注释说明,或查 TUI 类型定义确认——typecheck 必须过);
|
|
46
|
+
- 不改 `src/core/dashboard-render.mjs`(requestDashboardRender 保持差分语义,现有测试不动)。
|
|
47
|
+
|
|
48
|
+
### T2:冒烟测试(A1, A2)
|
|
49
|
+
|
|
50
|
+
1. 新增 `test-support/dashboard-shrink-render.ts`(范式照搬 dashboard-refs-render.ts:真实 root + createView + service + fake tui/theme/keybindings):
|
|
51
|
+
- fake tui 的 `requestRender` 记录调用参数(spy),`terminal.rows` 固定 40;
|
|
52
|
+
- 造 3 个 view → 第 1 次 render(160)(首帧);
|
|
53
|
+
- 第 2 次 render(160)(同数据,无变化);
|
|
54
|
+
- 删掉 1 个 view(service 的删除 API——先看 refs-render 或 service 里 deleteView/removeView 叫什么;若无删除 API,用第三个 view 的 cwd folder 折叠/直接操作 store 删目录亦可,目标是让内容行数减少);
|
|
55
|
+
- 第 3 次 render(160)(内容行数减少);
|
|
56
|
+
- 输出 JSON:`{ calls: [...] , ok: true }`(requestRender 的参数序列)。
|
|
57
|
+
2. `test/dashboard-render.test.mjs` 新增用例(execFileSync + --experimental-transform-types,照抄 refs 用例)断言:
|
|
58
|
+
- 首帧后 calls 含 `[true]`(D1);
|
|
59
|
+
- 第二帧(无变化)无新增 `[true]`;
|
|
60
|
+
- 第三帧(收缩)后再次出现 `[true]`(D2)。
|
|
61
|
+
|
|
62
|
+
### T3:全量回归(A4)
|
|
63
|
+
|
|
64
|
+
`npm test`(基线 617)+ `npm run typecheck`。
|
|
65
|
+
|
|
66
|
+
## 验收对账
|
|
67
|
+
- A1/A2 → T2 断言 · A3 → diff 审查(运行时改动仅 dashboard.ts)· A4 → T3
|
|
68
|
+
- U1(用户实测,合并后):密集增删 session 观察无残影。
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# issue #87 plan:legacy 死 host 安全回收实现
|
|
2
|
+
|
|
3
|
+
日期:2026-09-08 · spec:docs/superpowers/specs/2026-09-08-legacy-stale-host-recovery-design.md
|
|
4
|
+
|
|
5
|
+
## 任务拆解
|
|
6
|
+
|
|
7
|
+
### T1:纯决策函数 `canFinalizeLegacyHost`(A1)
|
|
8
|
+
|
|
9
|
+
**文件**:`src/core/host-coordination.mjs`(新增导出,放 `classifyProbeResult` 附近)
|
|
10
|
+
|
|
11
|
+
```js
|
|
12
|
+
/**
|
|
13
|
+
* Whether a legacy (pre-instanceId) host record can be safely finalized as
|
|
14
|
+
* exited: the runner pid is provably dead (a reused pid reads alive →
|
|
15
|
+
* conservative false) AND the endpoint is unreachable. Satisfies spec §10.1's
|
|
16
|
+
* "never recovered" conservatism — recovery only fires when identity is
|
|
17
|
+
* certain (issue #87).
|
|
18
|
+
* @param {{ host: HostStatus|null, hostPid: number|null|undefined, hostPidAlive: boolean, probeClassification: string }} input
|
|
19
|
+
* @returns {boolean}
|
|
20
|
+
*/
|
|
21
|
+
export function canFinalizeLegacyHost({ host, hostPid, hostPidAlive, probeClassification }) {
|
|
22
|
+
if (!host || host.instanceId != null) return false;
|
|
23
|
+
if (host.state !== "starting" && host.state !== "alive") return false;
|
|
24
|
+
if (hostPid == null || hostPidAlive) return false;
|
|
25
|
+
return probeClassification === "missing" || probeClassification === "stale";
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
**测试**:`test/host-coordination.test.mjs` 新增真值表用例(在文件既有风格下):
|
|
30
|
+
- 全条件满足(starting/alive × missing/stale 四组合)→ true
|
|
31
|
+
- instanceId 非 null → false
|
|
32
|
+
- state ∈ {exited, failed, stopping} → false
|
|
33
|
+
- hostPid null / hostPidAlive true → false
|
|
34
|
+
- classification ∈ {ready, starting, occupied, unknown} → false
|
|
35
|
+
- host null → false
|
|
36
|
+
|
|
37
|
+
### T2:resolver 两处改造(A2-A5)
|
|
38
|
+
|
|
39
|
+
**文件**:`src/runtime/service.mjs`
|
|
40
|
+
|
|
41
|
+
1. **import 更新**:`readHostPid` 加入 store.mjs import 列表;`canFinalizeLegacyHost` 加入 host-coordination.mjs import(L28 现有 `canReplaceHost` 同处)。
|
|
42
|
+
|
|
43
|
+
2. **新增模块内 helper**(resolver 函数前,模块作用域):
|
|
44
|
+
|
|
45
|
+
```js
|
|
46
|
+
/**
|
|
47
|
+
* Finalize a provably-dead legacy host as exited so the resolver loop's next
|
|
48
|
+
* iteration sees hostActive=false and claims a fresh new-protocol host
|
|
49
|
+
* (issue #87). Legacy records have no instanceId — no concurrent owner exists,
|
|
50
|
+
* so an unfenced writeHost is safe; the claim path is serialized by the
|
|
51
|
+
* host-start lease.
|
|
52
|
+
* @returns {boolean} true when finalized (caller should `continue` the loop).
|
|
53
|
+
*/
|
|
54
|
+
function finalizeDeadLegacyHost(root, viewId, host, probeClassification) {
|
|
55
|
+
const hostPid = Object.hasOwn(host, "runnerPid") ? host.runnerPid : readHostPid(root, viewId);
|
|
56
|
+
if (!canFinalizeLegacyHost({ host, hostPid, hostPidAlive: isAlive(hostPid), probeClassification })) return false;
|
|
57
|
+
writeHost(root, { ...host, state: "exited", endedAt: Date.now(), lastSeenAt: Date.now(), error: "legacy host finalized: runner pid dead (issue #87)" });
|
|
58
|
+
try {
|
|
59
|
+
appendDiagnostic(root, viewId, {
|
|
60
|
+
source: "service", level: "info", code: "legacy_host_finalized",
|
|
61
|
+
message: `Finalized stale legacy host (pid ${hostPid} dead, probe ${probeClassification}) — next attach claims a fresh host`,
|
|
62
|
+
details: { hostPid, probeClassification, previousState: host.state },
|
|
63
|
+
});
|
|
64
|
+
} catch { /* best effort */ }
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
注意:`writeHost` 签名是 `(root, host)`(host 内含 viewId),与 updateOwnedHost 不同。appendDiagnostic 签名参照文件内现有调用(`appendDiagnostic(root, viewId, {...})`,如 recoverHost 附近用法——实现时以现有调用为准)。
|
|
70
|
+
|
|
71
|
+
3. **resolver legacy alive 分支**(现 L963 附近):
|
|
72
|
+
|
|
73
|
+
```js
|
|
74
|
+
} else if (legacy) {
|
|
75
|
+
// Legacy host unreachable. spec §10.1 says never recover — unless the
|
|
76
|
+
// runner pid is provably dead, in which case finalize and self-heal
|
|
77
|
+
// (issue #87).
|
|
78
|
+
if (finalizeDeadLegacyHost(root, viewId, host, probe.classification)) {
|
|
79
|
+
await sleepFnImpl(HOST_PROBE_RETRY_MS);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
return pending(sessionFile, "legacy host unreachable — manual restart needed");
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
4. **resolver starting-legacy 分支**(现 L953-957 附近,`withinGrace || legacy` 等待分支):
|
|
87
|
+
|
|
88
|
+
```js
|
|
89
|
+
if (withinGrace || legacy) {
|
|
90
|
+
// A legacy starting host whose runner is provably dead would wait out
|
|
91
|
+
// the grace window forever (withinGrace is always true for legacy) —
|
|
92
|
+
// finalize it now instead (issue #87).
|
|
93
|
+
if (legacy && finalizeDeadLegacyHost(root, viewId, host, probe.classification)) {
|
|
94
|
+
await sleepFnImpl(HOST_PROBE_RETRY_MS);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
// Normal cold start (or a legacy starting host — legacy is never recovered,
|
|
98
|
+
// spec §10.1): wait out the grace window.
|
|
99
|
+
await sleepFnImpl(HOST_PROBE_RETRY_MS);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
(此分支 probe 已在上方 L939 执行,classification 可得。)
|
|
105
|
+
|
|
106
|
+
**测试**:`test/host-resolver.test.mjs` 新增 4 个测试(fixtures 复用 hostRecord/scriptProbe/instantSleep/resolverService;launchHost override 模仿现有 adoption 测试的成功 spawn 模式):
|
|
107
|
+
|
|
108
|
+
- **A2 自愈闭环**:legacy alive host(hostRecord:instanceId:null、state:"alive"、**无 runnerPid 属性**——用 delete 或构造时排除)+ host-pid.json 写死 pid(writeHostPid 或 readHostPid 对应写法,参照 store.mjs 导出;用不可能存活的 pid 如 999999——注意须确认该 pid 在测试机不存在,现有 fixture aliveHost 已用 999999 表达"死 pid",同法)+ scriptProbe(["missing"]) + launchHost override 成功 spawn → 断言:resolveAttachTarget 最终返回 kind:"pty"(新 host);readHost 状态变迁——最终 host 是新 instance(launchHost 写入 starting);diagnostics 含 legacy_host_finalized。
|
|
109
|
+
- 实现细节:claim/launch 后 readHost 读到的是新 host 记录,验证 finalize 发生要靠 diagnostics 断言 + resolve 结果非 pending。
|
|
110
|
+
- **A3 保守分支**:同上但 host-pid.json 写 `process.pid`(活 pid)→ 断言返回 pending(reason 含 "legacy host unreachable")且 host.json 未被改写(state 仍 alive)。
|
|
111
|
+
- **A4 starting-legacy 回收**:legacy starting host(claimAt 久远超 grace,pid 死)+ probe missing + launchHost spawn 成功 → 断言不自 deadline timeout、最终 kind:"pty"、diagnostics 含 legacy_host_finalized。
|
|
112
|
+
- **A5 unknown 不回收**:pid 死 + scriptProbe(["unknown"]) → 断言 pending 且不写盘。
|
|
113
|
+
|
|
114
|
+
⚠️ fixture 要点:hostRecord 默认写 `runnerPid: null`——这会让 `Object.hasOwn(host, "runnerPid")` 为 true,fallback 不到 host-pid.json。legacy fixture 必须构造**没有 runnerPid 属性**的记录(hostRecord 加 `legacy: true` 选项删除 instanceId/runnerPid 等字段,或新写 `legacyHostRecord` helper),并配 `writeHostPid(root, viewId, pid)` 写镜像。
|
|
115
|
+
|
|
116
|
+
### T3:全量回归(A6)
|
|
117
|
+
|
|
118
|
+
`npm test` + `npm run typecheck`(基线 602)。
|
|
119
|
+
|
|
120
|
+
## 验收对账
|
|
121
|
+
|
|
122
|
+
- A1 → T1 真值表单测
|
|
123
|
+
- A2/A3/A4/A5 → T2 四个集成测试
|
|
124
|
+
- A6 → T3
|
|
125
|
+
- U1(用户实测,合并前):本机 6 个真实卡死 legacy view attach 验证——主 session 合并前提示用户执行,或合并后实测反馈。
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# issue #86 plan:spawn 异步 error 兜底实现
|
|
2
|
+
|
|
3
|
+
日期:2026-09-08 · spec:docs/superpowers/specs/2026-09-08-spawn-async-error-swallow-design.md
|
|
4
|
+
|
|
5
|
+
## 任务拆解
|
|
6
|
+
|
|
7
|
+
### T1:launch.mjs spawnDetached helper + 4 处收敛(A1, A2)
|
|
8
|
+
|
|
9
|
+
**改动**(`src/core/launch.mjs`):
|
|
10
|
+
1. 文件底部(或 import 后)新增模块内 helper:
|
|
11
|
+
```js
|
|
12
|
+
function spawnDetached(command, args, cwd) {
|
|
13
|
+
const child = spawn(command, args, {
|
|
14
|
+
cwd,
|
|
15
|
+
detached: true,
|
|
16
|
+
stdio: "ignore",
|
|
17
|
+
env: process.env,
|
|
18
|
+
// Windows: detached children get their own console window unless
|
|
19
|
+
// suppressed (CREATE_NO_WINDOW; no-op on POSIX) — issue #49.
|
|
20
|
+
windowsHide: true,
|
|
21
|
+
});
|
|
22
|
+
// Swallow async spawn failures (e.g. transient ENOENT on the node binary):
|
|
23
|
+
// without an 'error' listener the EventEmitter rethrows as uncaughtException
|
|
24
|
+
// and takes down the whole host Pi process (issue #86). Callers already
|
|
25
|
+
// record state "failed" via the pid == null branch.
|
|
26
|
+
child.on("error", () => {});
|
|
27
|
+
child.unref();
|
|
28
|
+
return child;
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
2. `launchRun` / `launchHost` / `launchTitle` / `launchAutoState` 四处的 `spawn(...)` + `child.unref()` 收敛为 `const child = spawnDetached(node, [opts.runnerScript, configPath], config.cwd);`,返回值逻辑(`child.pid ?? null`、writePid 等)不变。
|
|
32
|
+
|
|
33
|
+
**测试**(`test/launch.test.mjs`,复用现有注入模式):
|
|
34
|
+
- 新增 1 个测试:注入 `node: "/nonexistent/node-ENOENT-test"`,依次调用 4 个入口,断言:各自正常返回 `{ pid: null }`;`process.on("uncaughtException")` spy 不被触发;等待 ~300ms 让异步 error 有机会冒泡(进程崩 = 测试天然失败)。
|
|
35
|
+
- 现有 4 个测试不动(A2 回归)。
|
|
36
|
+
|
|
37
|
+
### T2:pty-attach.ts openExternalTarget error 兜底(A3)
|
|
38
|
+
|
|
39
|
+
**改动**(`src/ui/pty-attach.ts` L1150-1167):三分支统一为:
|
|
40
|
+
```ts
|
|
41
|
+
const child = spawn("xdg-open", [sanitized], { detached: true, stdio: "ignore" });
|
|
42
|
+
child.on("error", () => {});
|
|
43
|
+
child.unref();
|
|
44
|
+
```
|
|
45
|
+
(darwin `open`、win32 `cmd /c start` 同理)。参照 L847-848 xclip 既有模式。
|
|
46
|
+
|
|
47
|
+
### T3:全量回归(A4)
|
|
48
|
+
|
|
49
|
+
`npm test` + `npm run typecheck`,568+ 全绿。
|
|
50
|
+
|
|
51
|
+
## 验收对账
|
|
52
|
+
|
|
53
|
+
- A1 → T1 新测试
|
|
54
|
+
- A2 → T1 既有测试回归
|
|
55
|
+
- A3 → T2 代码审查 + `rg 'child.on\("error"' src/ui/pty-attach.ts` 断言三分支(A3 static)
|
|
56
|
+
- A4 → T3
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# issue #90 plan:defaultModel 失效防护实现
|
|
2
|
+
|
|
3
|
+
日期:2026-09-08 · spec:docs/superpowers/specs/2026-09-08-stale-model-attach-guard-design.md
|
|
4
|
+
|
|
5
|
+
## 任务拆解
|
|
6
|
+
|
|
7
|
+
### T1:core 匹配函数 + service 注入(A1, A4)
|
|
8
|
+
|
|
9
|
+
1. `src/core/launch-options.mjs` 新增导出(spec D1.1 签名原样):
|
|
10
|
+
```js
|
|
11
|
+
export function modelRefAvailable(modelRef, availableModels) {
|
|
12
|
+
const ref = String(modelRef ?? "").trim().toLowerCase();
|
|
13
|
+
if (!ref) return true; // no constraint
|
|
14
|
+
if (!availableModels || availableModels.length === 0) return true; // can't judge → allow
|
|
15
|
+
return availableModels.some((m) => `${m.provider}/${m.id}`.toLowerCase() === ref);
|
|
16
|
+
}
|
|
17
|
+
```
|
|
18
|
+
2. 单测:若已有 `test/launch-options*.test.mjs` 则加入,否则新建。真值表:精确匹配(大小写混合)→ true;不匹配 → false;null/空串 → true;availableModels undefined/[] → true;部分匹配("glm-5.3" 不配 provider)→ false。
|
|
19
|
+
3. `src/ui/dashboard.ts` L1738 `findLaunchModelByRef` 改为内部调用 core 的匹配逻辑(保持返回 LaunchModel 对象:find 复用 `modelRefAvailable` 不可行——它返回 boolean;改为直接复用 core 里新增的共享比较,或最简单:dashboard 本地函数改为 `models.find(m => modelRefAvailable(ref, [m])) ?? null`。选最简单且单一事实源的写法)。
|
|
20
|
+
- 注:若这步让 diff 变脏(import 路径等),可以跳过,dashboard 本地函数保持——在 plan 偏差里说明即可。优先级低。
|
|
21
|
+
|
|
22
|
+
### T2:service 校验接线(A2, A3, A5)
|
|
23
|
+
|
|
24
|
+
1. `createService` opts 新增:`availableModels`(`() => Array<{provider:string,id:string}> | undefined`;默认 undefined → 跳过校验)。JSDoc typedef 更新(文件内 createService opts 注释处)。
|
|
25
|
+
2. service.mjs 模块内新增 helper:
|
|
26
|
+
```js
|
|
27
|
+
/** @returns {string|null} error message when the view's defaultModel is provably unavailable. */
|
|
28
|
+
function validateViewModelMeta(meta) {
|
|
29
|
+
const model = meta.defaultModel ?? null;
|
|
30
|
+
if (!model || !optsAvailableModels) return null;
|
|
31
|
+
const list = optsAvailableModels() ?? undefined;
|
|
32
|
+
if (modelRefAvailable(model, list)) return null;
|
|
33
|
+
return `Model "${model}" configured for this session is no longer available — update the view's model (or clear defaultModel) and retry attach.`;
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
(注意 availableModels() 调用本身 try/catch → undefined。)
|
|
37
|
+
3. `startHostUnderLease`(L205):在 canReplaceHost 检查之后、instanceId 生成之前插入:
|
|
38
|
+
```js
|
|
39
|
+
const modelError = validateViewModelMeta(meta);
|
|
40
|
+
if (modelError) return { ok: false, error: modelError };
|
|
41
|
+
```
|
|
42
|
+
(校验失败不留 claim、不 spawn。)
|
|
43
|
+
4. `adoptClaimedHost`(L775 起):config 构建前插入校验;失败时:
|
|
44
|
+
```js
|
|
45
|
+
updateOwnedHost(root, viewId, instanceId, (h) => ({ ...h, state: "failed", endedAt: nowImpl(), exitCode: 1, error: modelError, claimPid: null, claimIdentity: null }));
|
|
46
|
+
return { ok: true, pending: true, socketPath: null, instanceId };
|
|
47
|
+
```
|
|
48
|
+
(对齐该函数既有 spawnError 失败处理模式。)
|
|
49
|
+
5. `src/index.ts` serviceFor 与 `src/commands/agent-board.ts` flag 路径注入:
|
|
50
|
+
```ts
|
|
51
|
+
availableModels: () => { try { return ctx.modelRegistry.getAvailable(); } catch { return undefined; } },
|
|
52
|
+
```
|
|
53
|
+
(agent-board.ts flag 路径 L115 的 createService 调用同款注入;dashboard 路径 L95 已有 availableModels 变量但那是 UI deps,service 注入仍需单独传。)
|
|
54
|
+
6. 集成测试(`test/host-resolver.test.mjs` 或 `test/service.test.mjs`,看 ensure 测试在哪更顺):
|
|
55
|
+
- A2:view meta defaultModel="glm/glm-5.3",注入 availableModels: () => [{provider:"zai-coding-cn",id:"glm-5.3"}],launchHost spy → ensureHost("v1") 断言 ok:false、error 含模型名、spy 零调用、readHost 无 starting 残留;
|
|
56
|
+
- A3 对照:availableModels 含 glm/glm-5.3 → 正常 claim+spawn(断言 started/started pending 路径与现状一致);
|
|
57
|
+
- A4:不注入 availableModels → 跳过校验正常 launch;
|
|
58
|
+
- A5:废弃 starting claim(adopt 路径,参照现有 adoption 测试 fixture)+ 失效模型 → 断言落 failed、不 spawn。
|
|
59
|
+
|
|
60
|
+
### T3:runner exit 归因(A6, A7)
|
|
61
|
+
|
|
62
|
+
1. `src/core/heuristics.mjs` 新增导出:
|
|
63
|
+
```js
|
|
64
|
+
/**
|
|
65
|
+
* Last non-empty visible line of a raw terminal log chunk (ANSI/OSC stripped,
|
|
66
|
+
* per-line carriage-return resolved). @returns {string|null}
|
|
67
|
+
*/
|
|
68
|
+
export function lastVisibleLogLine(text, maxLen = 200)
|
|
69
|
+
```
|
|
70
|
+
实现:strip `/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g`(OSC)→ `/\x1b\[[0-9;?]*[ -/]*[@-~]/g`(CSI)→ 其他 `\x1b.` 单字符转义 → 按 `\n` 切分 → 每行取最后一个 `\r` 之后段 → trim → 过滤空 → 取最后一行 → truncate(maxLen)(复用同文件 truncate)。
|
|
71
|
+
2. 单测(heuristics 测试文件):ANSI 颜色、OSC 链接、`\r` 覆盖行、空行跳过、截断、空输入 → null。
|
|
72
|
+
3. `runner/pty-runner.mjs` child exit 回调(L213-223 区域):
|
|
73
|
+
```js
|
|
74
|
+
if (!crashed) {
|
|
75
|
+
let exitError = null;
|
|
76
|
+
if (exitCode !== 0) {
|
|
77
|
+
try {
|
|
78
|
+
const tail = readScreenLogTail(screenLog, 8192); // openSync/readSync seek 尾部
|
|
79
|
+
exitError = lastVisibleLogLine(tail);
|
|
80
|
+
} catch { /* best effort */ }
|
|
81
|
+
}
|
|
82
|
+
update({ state: "exited", endedAt: Date.now(), exitCode, childPid: null, ...(exitError ? { error: exitError } : {}) });
|
|
83
|
+
...
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
(screenLog 变量在 L62 作用域内可见;确认该回调能访问到。readScreenLogTail 写成 runner 内私有小函数。)
|
|
87
|
+
4. 集成测试(test/pty-runner.integration.test.mjs):用 test-support/ 下 fake pi 模式(看 fake-slow-start-pi.mjs;若无"立即 exit 1 + 输出错误"的 fake 就新增一个 `fake-failing-pi.mjs`:打印 `Error: Model "glm/glm-5.3" not found.` 后 exit 1)→ spawn runner → 等 exit → 断言 readHost 的 error 含 "Model" / "not found";对照 exit 0(现有 fake)→ error 字段保持 null。注意 hasNodePty skip 守卫参照现有测试。
|
|
88
|
+
|
|
89
|
+
### T4:全量回归(A8)
|
|
90
|
+
|
|
91
|
+
`npm test` + `npm run typecheck`(基线 608)。
|
|
92
|
+
|
|
93
|
+
## 验收对账
|
|
94
|
+
|
|
95
|
+
- A1 → T1.2 · A2/A3/A4/A5 → T2.6 · A6 → T3.2 · A7 → T3.4 · A8 → T4
|
|
96
|
+
- U1(用户实测,合并后):view_539a5e9e20 恢复失效 defaultModel → attach 看 notify + diagnostics 不再累积;改回有效模型 attach 成功。
|
|
@@ -63,7 +63,7 @@ repo.mjs remoteHost()(带缓存)──────────────
|
|
|
63
63
|
|---|---|---|
|
|
64
64
|
| claim(最强) | 认领命令:provider 规则里 `strength: "claim"` 的命令模式(GitHub 内置:`gh issue edit N --add-assignee`) | commands 正则 |
|
|
65
65
|
| claim | worktree 命名:`issue-<N>-<slug>`(issue-driven 工作流强制规范) | worktreePath / branch 结构化解析(非正则配置,引擎内置) |
|
|
66
|
-
| claim | PR
|
|
66
|
+
| claim | PR 回链(按证据上下文拆分,issue #65):`gh pr create` body 中的 `Closes #N` / `fixes issue #N` / 兼容裸 `issue #N`;后续 assistant 文本仅认 canonical `Closes/Fixes/Resolves #N`(带单词边界与 7 位编号边界);仅在恰好一个 PR create 命令时扫描 assistant,后续 command 不参与回链 | create 命令自身 + assistantTexts |
|
|
67
67
|
| action(强) | `issue comment/edit/close N`、`pr checkout/view/merge N`、`pr create`(编号从 outputUrl 或后续 URL 反查,见 D4 限制) | commands 正则 |
|
|
68
68
|
| view(中) | `issue view N` / `pr view N` | commands 正则,要求频次 ≥2,取最近一次 |
|
|
69
69
|
| mention(弱,兜底) | 裸 `#N` | assistantTexts,要求频次显著最高(≥3 且 ≥ 第二名的 2 倍) |
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# Spec: 收窄 code-refs pr-backlink 提取(issue #65)
|
|
2
|
+
|
|
3
|
+
- Issue: https://github.com/zhuxixi/pi-agent-board/issues/65
|
|
4
|
+
- 日期:2026-09-02
|
|
5
|
+
- 状态:已确认(2026-09-03,含 review 修订)
|
|
6
|
+
|
|
7
|
+
## 1. 背景与根因(已确认)
|
|
8
|
+
|
|
9
|
+
board 行在「PR 已创建、issue 未关闭」窗口期内把 issue 徽标误显示为 `#1`(正确应为 `#19`)。根因分为直接触发和放大因素:
|
|
10
|
+
|
|
11
|
+
1. **直接触发是后续证据复用了过宽的正则**:`src/core/code-refs.mjs:510` 的 `PR_BACKLINK_RE` 含 `issue\s+#\d+` 分支,无 closing keyword 锚定。CR 报告模板文本「本轮仅验证上轮 issue #1(no-pushback)」中的 `#1` 是 review finding 编号,却被采为 claim 级 pr-backlink 候选。
|
|
12
|
+
2. **放大因素是 4b 的证据归属过宽**:`resolveBacklinkAfter`(:651)会把 `pr create` 后的后续 command 与 assistant 文本都视为同一 PR 的回链候选;误匹配的 lastIndex≈158 比真实 #19 信号(PR 正文回链≈66、worktree 命名=-1)更靠后,同强度按 lastIndex 决胜 → `#1` 胜出。
|
|
13
|
+
|
|
14
|
+
`buildEngineInput` 将最近 200 条命令与最近 20 条 assistant 文本分别截取,再把两组数组拼成一条“命令在前、assistant 在后”的伪序列;它没有保留两类证据之间的真实 `at` 时序。因此“最后 3 条命令 / 前 3 条 assistant”不能可靠表示“紧随 PR create 的消息”,本 spec 不采用这个索引启发式。
|
|
15
|
+
|
|
16
|
+
窗口滑动后 winner 回到 #19 的现象,以及旧 #1 通过 `mergeWithExisting` 留在 `allRefs` 的现象均已确认;后者属于历史 artifact 迁移边界,单独列入非目标。
|
|
17
|
+
|
|
18
|
+
## 2. 修复设计
|
|
19
|
+
|
|
20
|
+
### F1:按证据上下文拆分 PR 回链正则(主修复)
|
|
21
|
+
|
|
22
|
+
不再让一个宽正则同时处理 PR create 命令和后续 assistant 文本,改为两个纯数据规则:
|
|
23
|
+
|
|
24
|
+
```js
|
|
25
|
+
// Explicit PR-create command: preserve the legacy `issue #N` body form.
|
|
26
|
+
const PR_CREATE_BACKLINK_RE =
|
|
27
|
+
/\b(?:(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\b\s+(?:issue\s+)?|issue\s+)#(\d{1,7})(?!\w)/i;
|
|
28
|
+
|
|
29
|
+
// Later assistant evidence: accept only canonical closing-keyword syntax.
|
|
30
|
+
const PR_FOLLOWUP_BACKLINK_RE =
|
|
31
|
+
/\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\b\s+#(\d{1,7})(?!\w)/i;
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
- 两个规则都增加单词边界,避免 `prefix #1`、`disclose #2`、`unresolved #3` 这类长单词子串误命中;编号后的 `(?!\w)` 防止把超过 7 位的数字或紧随字母/下划线的 token 截断成前 7 位。
|
|
35
|
+
- `applyPrBacklink` 只在 provider 命中的 PR create 命令上使用 `PR_CREATE_BACKLINK_RE`。PR create 命令中的 `issue #N` 是用户显式提供的 body 内容,保留原 PR #43 的兼容契约。
|
|
36
|
+
- `resolveBacklinkAfter` 只对 assistant 文本使用 `PR_FOLLOWUP_BACKLINK_RE`。后续 assistant 仅接受 `close/closed/closes #N`、`fix/fixed/fixes #N`、`resolve/resolved/resolves #N`,不接受裸 `issue #N`,也不接受 `fixes issue #N`。
|
|
37
|
+
- 两类规则都保留 `claim` 强度;不通过降级强度掩盖误报。
|
|
38
|
+
|
|
39
|
+
### F2:去掉后续 command 的通用回链扫描,不实现伪时序窗口
|
|
40
|
+
|
|
41
|
+
当前输入模型无法安全实现“PR create 后 N 条证据”这样的时间窗口,因此本 issue 采用可证明的来源边界,而不是增加固定数量常量:
|
|
42
|
+
|
|
43
|
+
- PR create 命令自身仍由 `PR_CREATE_BACKLINK_RE` 处理。
|
|
44
|
+
- `resolveBacklinkAfter` **只遍历 assistantTexts**,不再遍历 marker 后的任意 command。`gh issue close #21`、`gh pr comment 20 --body "fixes #21"`、`echo "closes #40"` 都不能被升级成 `pr-backlink` claim;它们若命中 provider 自己的 command 规则,仍保留其原本的 issue/pr action 语义,但 source 不得是 `pr-backlink`。
|
|
45
|
+
- **只有恰好一个不同 command index 的 PR create marker 时**,Rule 4b 才调用 assistant backlink resolver;同一条 create 命令若因用户规则与内置规则同时命中,仍只算一个 marker。没有 marker 或存在多个不同 PR create 命令时不产生 assistant `pr-backlink` 候选。这样在当前缺少真实时序的输入模型下,宁可漏掉多 PR 场景的 assistant 回链,也不把一条文本错误归属给某个 PR。
|
|
46
|
+
- resolver 接收 `assistantTexts` 与 `commands.length` 这个 `baseIndex`,按 assistantTexts 保留顺序返回第一个 canonical 命中,并以 `baseIndex + assistantIndex` 记录 `lastIndex`。这里的 `baseIndex` 仅用于保持现有排序契约,不表示真实时间。
|
|
47
|
+
- 不按当前扁平索引增加固定 assistant 数量或固定时间窗口。即使只有一个 PR marker,assistant 的真实 `at` 时序目前仍未进入 `buildEngineInput`,晚到的 canonical 句式仍可能被误归属;这是明确记录的残余风险。若要做到“紧邻 assistant 总结”的严格归属,必须先引入保留 `kind/text/at/sequence` 的 timestamped ordered-evidence 输入,另开 issue 设计。
|
|
48
|
+
- 后续 `gh pr edit --body/--body-file` 的回链归属不在本 issue 承诺范围内;只有被纳入 assistant evidence 且符合 canonical closing 语法的文本才可能被识别。
|
|
49
|
+
|
|
50
|
+
### F3:同步文档、测试和历史边界
|
|
51
|
+
|
|
52
|
+
- 更新 `src/core/code-refs.mjs` 注释,以及 `docs/superpowers/specs/2026-08-29-code-refs-badges-design.md` 中关于 PR 回链语法和 4b 来源的描述:create body 可兼容 `issue #N`;后续 assistant 只认 canonical closing 语法;任意后续 command 不属于 PR 回链。原始 design doc 是已提交历史文档,本次同步更新必须在 worktree 中与代码、测试同一提交完成。
|
|
53
|
+
- 通过 extractor 测试和 store 组合测试验证新 extraction 不会产生 issue #1;不改变 store/渲染运行逻辑。
|
|
54
|
+
- 本 issue 只保证**新一轮 extraction**不再从 CR 文本产生 `#1`;已有 `github.json` 中的旧 `pr-backlink` ref 不回溯清理,`mergeWithExisting` 的 carry-forward 语义不变。若要求升级后立即清除历史误 ref,需要另一个 artifact migration 设计。
|
|
55
|
+
|
|
56
|
+
### 不采纳的方向
|
|
57
|
+
|
|
58
|
+
- **按扁平索引加“最后 3 条”窗口**:时序信息不存在,边界不可证明,可能同时漏掉真实 assistant 总结并误收更晚文本。不采纳。
|
|
59
|
+
- **把 4b 降级为 action**:改变 spec D1 设计语义(PR 回链 = claim),且误 ref 仍会残留在 `allRefs`/peek。不采纳。
|
|
60
|
+
- **本 issue 内引入 timestamped ordered-evidence**:这是解决长期归属准确性的正确方向,但会扩大 evidence 输入契约和迁移面;作为后续独立设计,不与本次精确误报修复捆绑。
|
|
61
|
+
|
|
62
|
+
## 3. 验收矩阵
|
|
63
|
+
|
|
64
|
+
以下验收同时覆盖“候选没有产生”和“用户可见 winner 没被错误候选抢走”两层;表中命令均可在本地纯数据 fixture 中执行。
|
|
65
|
+
|
|
66
|
+
| ID | 功能点 | 验收方式 | 具体验证 | 通过标准 |
|
|
67
|
+
|----|--------|----------|----------|----------|
|
|
68
|
+
| A1 | issue #65 的真实误报路径不再产生 #1 | 自动化验证(unit) | `node --test test/code-refs-extract.test.mjs` | fixture 含 PR body `Closes #19`、worktree `issue-19-*`、assistant 文本「上轮 issue #1」「pushback verdict for issue #1」;`result.issue.number === 19`,且 `result.allRefs` 完全不含 `kind=issue, number=1`(无论 source) |
|
|
69
|
+
| A2 | create body 兼容性与严格 follow-up 语法 | 自动化验证(unit) | 同上 | create 命令中的裸 `issue #40` 仍得到 `source=pr-body`;create body 与 assistant 中的 `Closes/Fixes/Resolves #40` 按各自上下文正确命中;assistant 中裸 `issue #1`、`fixes issue #1`、`prefix #1`、`disclose #2`、`unresolved #3` 及超过 7 位编号均不作为 follow-up backlink,且上述负例不进入 `allRefs` |
|
|
70
|
+
| A3 | 后续 command 不被冒充为 PR 回链;多 PR assistant 不产生歧义归属 | 自动化验证(unit) | 同上 | create `#19` 后的 `gh issue close #21`、`gh pr comment ... fixes #21`、`echo "closes #21"` 不产生 `source=pr-backlink, number=21`;存在两个 PR create marker 时,后续 assistant 的 canonical 回链不产生任何 `pr-backlink` 候选(不猜测归属) |
|
|
71
|
+
| A4 | 持久化组合路径不产生新的误 ref | 自动化验证(integration) | `node --test test/code-refs-store.test.mjs` | 通过 `updateCodeRefsFromEvidence` 写入新 `github.json` 后,winner 为 #19,`allRefs` 不含新产生的 `pr-backlink #1`;不要求清理预先存在的历史 artifact |
|
|
72
|
+
| A5 | 全量回归与发布包完整性 | 自动化验证(unit + static + build) | `npm run verify` | typecheck、全测试、c8 coverage(lines ≥85%、functions ≥80%、branches ≥70%)及 `npm pack --dry-run` 全部通过 |
|
|
73
|
+
|
|
74
|
+
无用户实测项:本次行为改动限定在 `code-refs.mjs` 纯函数提取器、对应测试和文档,零网络、零新的持久化协议;历史 artifact 清理明确不属于本次验收。
|
|
75
|
+
|
|
76
|
+
## 4. 可测性拆分设计
|
|
77
|
+
|
|
78
|
+
改动主体落在 `src/core/code-refs.mjs`(既有纯函数模块,零 I/O、无副作用),并更新 extractor 测试、store 组合测试和设计文档;不修改 store/渲染运行逻辑:
|
|
79
|
+
|
|
80
|
+
- **`PR_CREATE_BACKLINK_RE` + `matchPrCreateBacklink(text)`**:只负责 PR create 命令自身的回链语法(包括兼容的 legacy `issue #N`)。输入/输出为字符串与正整数或 `null`;通过 `extractCodeRefs` 测 body 正例、closing keyword 单词边界和 7 位编号边界。
|
|
81
|
+
- **`PR_FOLLOWUP_BACKLINK_RE` + `matchPrFollowupBacklink(text)`**:只负责后续 assistant 的 canonical closing 语法。测试 `Closes/Fixes/Resolves #N` 正例,以及裸 `issue #N`、`fixes issue #N`、嵌入长单词、超过 7 位编号等负例;同时验证命中后只产生 `source=pr-backlink`,不会把同一文本中的其他裸 `#N` 误升级。
|
|
82
|
+
- **`resolveBacklinkAfter(assistantTexts, baseIndex)`**:保持纯函数,只遍历 assistant 文本并使用 follow-up matcher;不再接收或扫描 command,也不再需要 marker/`stopBefore`。调用方仅在不同 PR create command index 恰好为 1 时调用一次;多 PR 场景直接跳过 resolver,通过 `extractCodeRefs` 黑盒测试这一保守边界,不为内部细节新增公共导出。
|
|
83
|
+
- **store 组合边界**:使用临时 root 和脱敏 evidence 调用 `updateCodeRefsFromEvidence`,确认 extractor 结果经过 artifact 合并后仍不新增 #1;单独断言“历史 artifact 不自动清理”,避免把非目标误写成已修复。
|
|
84
|
+
- **副作用隔离**:不读网络、不改变 `github.json` schema、不改 `mergeWithExisting`;timestamped ordered-evidence 归属模型和 artifact migration 另行设计。
|
|
85
|
+
|
|
86
|
+
## 5. 非目标
|
|
87
|
+
|
|
88
|
+
- 不按当前扁平 `commands + assistantTexts` 索引实现固定数量/时间窗口;不在本 issue 改造 timestamped ordered-evidence 输入。
|
|
89
|
+
- 不扫描任意后续 command 作为 `pr-backlink`;不承诺多 PR 场景的 assistant 回链归属;不承诺后续 `gh pr edit --body/--body-file` 的独立归属。
|
|
90
|
+
- 不清理已经写入 `github.json` 的旧误 ref;carry-forward 机制本身不变,升级后的历史清理另开 artifact migration issue。
|
|
91
|
+
- 不动 mention 兜底(issue #61 是独立问题)。
|
|
92
|
+
- 不改变 providers.json schema;两个 backlink 正则是引擎内置的上下文规则,不能由 provider 规则配置覆盖。
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Design: evidence outputPreview 从 AgentToolResult 正确提取文本(issue #41)
|
|
2
|
+
|
|
3
|
+
状态:approved(用户确认于 2026-09-04)
|
|
4
|
+
仓库:zhuxixi/pi-agent-board · 调研留档:`~/.claude/github-issue-driven/zhuxixi/pi-agent-board/issue-41/research/pi-tool-result-structure.md`
|
|
5
|
+
|
|
6
|
+
## 1. 问题与根因(调研已闭环)
|
|
7
|
+
|
|
8
|
+
- 现象:`evidence.json` 的 `commands[].outputPreview` 对 bash 命令一律为 `"[object Object]"`,真实输出丢失(`~/.pi/agent/agent-board/views/` 抽样 100% 复现)。
|
|
9
|
+
- 根因:`src/core/evidence.mjs` `reduceEvidence` 的 `tool_execution_end` → bash 分支用 `String(event.result ?? "")`;pi 的 `result` 是 `AgentToolResult` 对象(`{ content: (TextContent|ImageContent)[], details, ... }`),文本在 `content[]` 的 `type==="text"` block 里。
|
|
10
|
+
- 漏网原因:现有单测 fixture 把 `result` 写成字符串 `"ok"`,与真实事件结构不符。
|
|
11
|
+
- pi 官方提取模式(`convertToolResultOutput`):`content.filter(c => c.type === "text").map(c => c.text).join("\n")`。
|
|
12
|
+
|
|
13
|
+
## 2. 设计决策表
|
|
14
|
+
|
|
15
|
+
| ID | 决策 | 理由 |
|
|
16
|
+
|----|------|------|
|
|
17
|
+
| D1 | 新增纯函数 `toolResultText(result)`,放 `src/core/heuristics.mjs`,与既有 `assistantText`(message.content text-block 提取)同文件、同防御模式 | 模式对称,仓库先例;不新建文件 |
|
|
18
|
+
| D2 | 行为:`null/undefined → ""`;`string → 原样`(兼容旧 fixture/历史回放);`对象 + Array.isArray(content) → filter(type==="text" && typeof text==="string") → map → join("\n") → trim`(纯 image → "");**对象无 content 数组 / 无 text block → `""`(宁缺毋滥)**;标量(number/bool 等)→ `String()` | 照抄 `assistantText` 防御式 + pi 官方 join("\n") 语义;兜底遵循 #40「宁可不显示也不错显示」原则——未知形状对象不再产生 `[object Object]`(即本 bug 的兜底复现路径,见 Review F1) |
|
|
19
|
+
| D3 | `reduceEvidence` 仅改一行:`outputPreview: truncate(toolResultText(event.result), 500)` | 单点修复,先提全文再截 500(与现状顺序一致) |
|
|
20
|
+
| D4 | 不动 `EvidenceCommand` 数据结构、不动其他工具分支、不迁移历史 evidence.json | 历史输出物理丢失无法恢复;其他工具本就无 outputPreview 提取 |
|
|
21
|
+
| D5 | 向后兼容:旧字符串 `result` 的既有 fixture `"ok"` 必须继续通过 | 防止修复破坏旧事件回放语义 |
|
|
22
|
+
|
|
23
|
+
## 3. 可测性拆分设计(硬约束)
|
|
24
|
+
|
|
25
|
+
- `toolResultText(result)`:**纯函数**,零副作用、零依赖 pi 运行时,输入任意 → 输出 string。测试边界:unit 直接构造各形状输入断言输出(单 text block / 多 text block join "\n" / 纯 image → "" / **对象无 content 字段 → ""** / string 原样 / null/undefined → "" / number/bool 标量 → String())。
|
|
26
|
+
- `reduceEvidence` bash 分支:保持现有快照式测试模式(构造 event → 断言 snapshot),仅补真实 `AgentToolResult` 形状 fixture;**不得**把提取逻辑内联进 reduceEvidence(保持函数已拆分,实现阶段不得耦合回去)。
|
|
27
|
+
- 测试层级选择:全部行为 unit 层可证(纯函数 + 快照),无需 integration/E2E mock 整个 pi runtime(成本高于收益)。真实 pi 进程链路(extension 加载 → bash 工具执行 → evidence 落盘)无法在单测内稳定脚本化,划给 U1 用户实测。
|
|
28
|
+
|
|
29
|
+
## 4. 验收矩阵
|
|
30
|
+
|
|
31
|
+
| ID | 功能点 | 验收方式 | 具体验证 | 通过标准 |
|
|
32
|
+
|----|--------|----------|----------|----------|
|
|
33
|
+
| A1 | `toolResultText` 纯函数各形状行为 | 自动化验证(unit) | `node --test test/heuristics.test.mjs` | 新增用例全过:AgentToolResult 形状提取、多 block join("\n")、纯 image → ""、对象无 content → ""(F1 兜底)、string 原样、null → ""、标量 String() 兜底 |
|
|
34
|
+
| A2 | `reduceEvidence` bash 分支用真实结构 | 自动化验证(unit) | `node --test test/evidence.test.mjs` | 真实 AgentToolResult fixture → outputPreview 为提取文本且 ≠ "[object Object]";既有字符串 fixture `"ok"` 断言不回归(D5) |
|
|
35
|
+
| A3 | 全量回归 | 自动化验证(unit+static) | `npm run verify` | 全部通过(注意勿用 `npm test -- <file>`,glob 会展开全量) |
|
|
36
|
+
| U1 | 真实 board 运行链路 | 用户实测 | 真实 pi session 里跑若干 bash 命令 → 查 `~/.pi/agent/agent-board/views/<view>/evidence.json`(**可执行时机:merge 发布、board 随日常 pi session 重启加载新版 extension 后**) | 新记录的 bash 命令 outputPreview 含真实输出文本(如 `gh pr create` 的 URL),不再出现 `[object Object]` |
|
|
37
|
+
|
|
38
|
+
## 5. 非目标
|
|
39
|
+
|
|
40
|
+
- 不修历史 evidence.json(数据已丢);不清理历史 `[object Object]` 记录。
|
|
41
|
+
- 不动其他工具的 result 处理与 code-refs 引擎(#41 修复后解锁 #40 的 outputUrl 路径,属后续工作)。
|
|
42
|
+
- 不改 500 字符截断长度与 `upsertEvidenceCommand` 覆盖语义。
|
|
43
|
+
- **不处理 powershell 工具**(Windows 下 pi 的 shell 工具是 powershell,其命令本就不进 commands 列表——既有行为,与本 bug 无关)。
|
|
44
|
+
|
|
45
|
+
## 6. 改动文件清单
|
|
46
|
+
|
|
47
|
+
- `src/core/heuristics.mjs`:+`toolResultText`(~12 行)
|
|
48
|
+
- `src/core/evidence.mjs`:import + 改 1 行
|
|
49
|
+
- `test/heuristics.test.mjs`:+纯函数用例
|
|
50
|
+
- `test/evidence.test.mjs`:+真实结构 fixture 用例
|