@zhuxixi/pi-agent-board 0.4.2 → 0.5.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.
@@ -0,0 +1,223 @@
1
+ # Code-Refs Badges 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:** Show each board row's associated issue number and submitted PR number as inline badges, extracted locally from session evidence via a platform-agnostic regex-rule engine.
6
+
7
+ **Architecture:** Pure extraction engine (`src/core/code-refs.mjs`, zero I/O) driven by per-platform regex rule bundles ("providers": builtin GitHub/GitLab + user `providers.json`, append-merged); artifact persistence in `src/core/code-refs-store.mjs` (per-view `github.json`, atomic writes); write-through hooks at all five `writeEvidence` call sites; rendering via existing RowView badge + peek detail patterns. Spec: `docs/superpowers/specs/2026-08-29-code-refs-badges-design.md` (decisions D1–D5 govern).
8
+
9
+ **Tech Stack:** Node 20+ ESM `.mjs` (JSDoc types, no TS in core), `node --test`, pi-agent-board store layout.
10
+
11
+ ## Global Constraints
12
+
13
+ - **Work only in this worktree**: `/home/elling/git-repo/github/pi-agent-board/.pi/worktrees/issue-40-code-refs-badges`. Never touch the main checkout.
14
+ - Core modules are `.mjs` with JSDoc typedefs; **indent with tabs** (match existing files); `node:assert/strict` + `node:test` for tests; tmp dirs via `mkdtempSync(join(tmpdir(), ...))` with `rmSync(..., {recursive:true, force:true})` cleanup.
15
+ - All artifact writes go through `atomicWriteJson` from `src/core/atomic.mjs`; reads through `readJson`.
16
+ - Commit per task, conventional commits (`feat:`/`test:`/`fix:`), stage files explicitly (`git add <file>`), never `git add -A`.
17
+ - Coverage gate (CI-enforced): lines 85% / functions 80% / branches 70% (`npm run test:coverage`); new core modules must be thoroughly covered.
18
+ - No network calls anywhere in v1. `gh`/`glab` are only ever *parsed as text*, never executed. Only `git` may be shelled out to (repo.mjs pattern: `execFileSync` with `stdio:["ignore","pipe","ignore"]`, try/catch → null).
19
+ - Kill switch: env `AGENT_BOARD_CODE_REFS=off` disables extraction (checked in the hook helper).
20
+ - English code comments and commit messages.
21
+
22
+ ---
23
+
24
+ ### Task 1: `gitRemoteHost` in repo.mjs
25
+
26
+ **Files:**
27
+ - Modify: `src/core/repo.mjs`
28
+ - Test: `test/repo.test.mjs`
29
+
30
+ **Interfaces:**
31
+ - Produces: `gitRemoteHost(repoRoot: string) → string|null` — host of `origin` remote, lowercase, no port. Supports `https://host/owner/repo(.git)` and `git@host:owner/repo(.git)`. Module-level `Map` cache keyed by repoRoot (failures cached as null). Also `clearRemoteHostCacheForTests() → void`.
32
+ - Consumed by: Task 4 (store) / Task 5 (hook helper).
33
+
34
+ - [ ] **Step 1: failing tests** in `test/repo.test.mjs` (follow existing temp-repo style, `skip: !gitAvailable()`):
35
+ - https remote `https://github.com/zhuxixi/pi-agent-board.git` → `"github.com"`
36
+ - ssh remote `git@gitlab.example.com:team/demo.git` → `"gitlab.example.com"`
37
+ - no remote → `null`; not a repo → `null`
38
+ - cache: second call returns same value; after `git remote set-url` + no cache clear, still old value; after `clearRemoteHostCacheForTests()`, new value
39
+ - [ ] **Step 2: run tests, see them fail** (`node --test test/repo.test.mjs`)
40
+ - [ ] **Step 3: implement** in `src/core/repo.mjs` (same `execFileSync("git", ["-C", root, "remote", "get-url", "origin"], …)` pattern as `gitRepoRoot`; parse with two regexes; cache both hits and misses)
41
+ - [ ] **Step 4: tests pass**
42
+ - [ ] **Step 5: commit** `feat(repo): gitRemoteHost with per-root cache`
43
+
44
+ ---
45
+
46
+ ### Task 2: provider layer in code-refs.mjs (schema, builtins, merge, host match)
47
+
48
+ **Files:**
49
+ - Create: `src/core/code-refs.mjs`
50
+ - Test: `test/code-refs-providers.test.mjs`
51
+
52
+ **Interfaces:**
53
+ - Produces (all pure, zero I/O except `loadProviders` reading one JSON file path passed in):
54
+ - `builtinProviders() → Provider[]` — GitHub + GitLab bundles (rules below)
55
+ - `genericFallbackProvider() → Provider` — name `"generic"`, hosts `[]`, URL-only rules: `/issues/(\d+)` (issue/view), `/pull/(\d+)` (pr/action), `/-/issues/(\d+)` (issue/view), `/-/merge_requests/(\d+)` (pr/action); prefixes `#` / `▸#`; no urlTemplates
56
+ - `validateProvider(raw: any) → { provider: Provider|null, errors: string[] }` — each rule compiled with `new RegExp(pattern)`; invalid regex → skipped with error message; missing required fields → error
57
+ - `mergeProviders(builtins: Provider[], user: Provider[]) → Provider[]` — same `name` → user rules **prepended** to builtin rules, user scalar fields (hosts/prefixes/urlTemplates) override; unknown names appended as-is
58
+ - `loadProviders(root: string) → Provider[]` — reads `<root>/providers.json` if present (via `readJson` from atomic.mjs), validates + merges with builtins; on any error returns builtins alone (never throws). Result cached by file mtime in a module Map.
59
+ - `matchProvider(providers: Provider[], host: string|null) → Provider` — exact host match (lowercase); null host or no match → `genericFallbackProvider()`
60
+ - Provider typedef: `{ name, hosts: string[], issuePrefix: string, prPrefix: string, urlTemplates: { issue?: string, pr?: string }|null, rules: Rule[] }`; Rule typedef: `{ regex: RegExp, pattern: string, kind: "issue"|"pr", strength: "claim"|"action"|"view", numberFrom: "capture"|"outputUrl" }`
61
+ - Consumed by: Task 3 (engine), Task 5 (hook helper calls `loadProviders`).
62
+
63
+ Builtin GitHub rules (patterns are matched **unanchored** against full command strings and assistant texts):
64
+ ```
65
+ claim: gh\s+issue\s+edit\s+#?(\d+)(?=[\s\S]*--add-assignee) kind: issue
66
+ action: gh\s+issue\s+(?:comment|edit|close|reopen)\s+#?(\d+) kind: issue
67
+ action: gh\s+issue\s+create\b kind: issue, numberFrom: outputUrl
68
+ action: gh\s+pr\s+(?:checkout|merge|comment|review|close)\s+#?(\d+) kind: pr
69
+ action: gh\s+pr\s+create\b kind: pr, numberFrom: outputUrl
70
+ action: github\.com/[\w.-]+/[\w.-]+/pull/(\d+) kind: pr
71
+ view: gh\s+issue\s+view\s+#?(\d+) kind: issue
72
+ view: gh\s+pr\s+(?:view|diff|checks)\s+#?(\d+) kind: pr
73
+ view: github\.com/[\w.-]+/[\w.-]+/issues/(\d+) kind: issue
74
+ urlTemplates: issue "https://{host}/{owner}/{repo}/issues/{number}", pr "https://{host}/{owner}/{repo}/pull/{number}"
75
+ ```
76
+ Builtin GitLab rules:
77
+ ```
78
+ claim: glab\s+issue\s+(?:edit|update)\s+#?(\d+)(?=[\s\S]*--assignee) kind: issue
79
+ action: glab\s+issue\s+(?:note|comment|close|reopen)\s+#?(\d+) kind: issue
80
+ action: glab\s+mr\s+(?:checkout|merge)\s+!?(\d+) kind: pr
81
+ action: glab\s+mr\s+create\b kind: pr, numberFrom: outputUrl
82
+ action: /-/merge_requests/(\d+) kind: pr
83
+ view: glab\s+(?:issue|mr)\s+view\s+!?#?(\d+) kind: issue-or-pr by matched subcommand — implement as two rules: glab\s+issue\s+view\s+#?(\d+) (issue) and glab\s+mr\s+view\s+!?(\d+) (pr)
84
+ view: /-/issues/(\d+) kind: issue
85
+ prefixes: issue "#", pr "!"
86
+ urlTemplates: issue "https://{host}/{owner}/{repo}/-/issues/{number}", pr "https://{host}/{owner}/{repo}/-/merge_requests/{number}"
87
+ ```
88
+
89
+ - [ ] **Step 1: failing tests**: builtin shape sanity (every rule regex compiles, kinds/strengths in enum); validateProvider rejects bad regex / missing kind; mergeProviders prepends user rules and overrides prefixes; loadProviders with missing file → builtins; with broken JSON → builtins; with valid user file → merged; matchProvider exact/lowercase/fallback
90
+ - [ ] **Step 2: run, fail**
91
+ - [ ] **Step 3: implement**
92
+ - [ ] **Step 4: tests pass**
93
+ - [ ] **Step 5: commit** `feat(code-refs): provider schema, builtin github/gitlab rules, append-merge loading`
94
+
95
+ ---
96
+
97
+ ### Task 3: extraction + scoring engine in code-refs.mjs
98
+
99
+ **Files:**
100
+ - Modify: `src/core/code-refs.mjs`
101
+ - Test: `test/code-refs-extract.test.mjs`
102
+
103
+ **Interfaces:**
104
+ - Consumes: Provider/Rule from Task 2.
105
+ - Produces:
106
+ - `extractCodeRefs(input, provider) → CodeRefsResult`
107
+ - `input: { commands: Array<{ command: string }>, assistantTexts: string[], worktreePath: string|null, branch: string|null, repoUrl: string|null }` (`repoUrl` = `owner/repo` path part of the remote, used for urlTemplates; may be null)
108
+ - `CodeRefsResult: { provider: string, issue: Ref|null, pr: Ref|null, allRefs: Ref[] }`
109
+ - `Ref: { kind: "issue"|"pr", number: number, strength: "claim"|"action"|"view"|"mention", confidence: "high"|"medium"|"low", source: string, url: string|null, lastIndex: number }`
110
+ - `parseRepoPath(repoRoot) → string|null` — **move-free helper**: parse `owner/repo` from remote URL. NOTE: Task 1 didn't produce this; add `gitRemoteUrl(repoRoot)` to repo.mjs here (same pattern, also cached) returning the raw URL, and keep URL→host and URL→path parsing pure in code-refs.mjs (`parseRemoteHost(url)`, `parseRemotePath(url)`). Refactor Task 1's `gitRemoteHost` to use `gitRemoteUrl` + `parseRemoteHost` internally. Update repo tests accordingly.
111
+
112
+ Engine rules (implement exactly):
113
+ 1. Scan `commands` in array order; each rule regex applied to `command`; capture group 1 = number (rules with `numberFrom:"outputUrl"` yield no number here — record a *pending create* marker with kind+index).
114
+ 2. Scan `assistantTexts` (treat as ordered sequence after commands, indexes continue) with only the URL rules (patterns containing `/` path segments — select rules whose pattern contains `issues/|pull/|merge_requests/`).
115
+ 3. Pending-create resolution: for each `pr create`/`issue create`/`mr create` marker, search **subsequent** texts/commands for the provider's URL rule of the same kind; first hit assigns the number at strength `action`, `source:"create-url"`. Unresolved markers contribute nothing (outputPreview bug #41 means bash output is unreadable — do **not** read outputPreview).
116
+ 4. PR back-link: within a `pr create` command string, `(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?|issue)\s+#(\d{1,7})` → issue ref at `claim` strength, `source:"pr-body"`.
117
+ 5. Worktree naming (engine-builtin, not configurable): `(?:^|[/\\])issue-(\d{1,7})(?:-|$)` against `worktreePath` and `branch` → issue ref at `claim`, `source:"worktree"`.
118
+ 6. Mention fallback (only if no issue candidate of strength ≥ view exists): count `#(\d{1,7})` over the **last 20** assistantTexts; winner needs count ≥3 **and** ≥ 2× runner-up; strength `mention`, confidence `low`, kind issue, no url.
119
+ 7. Aggregation per kind: candidate with highest strength wins; tie → highest `lastIndex`; `view` candidates with total count < 2 are discarded first. Confidence: claim/action → `high`, view → `medium`, mention → `low`. `allRefs`: distinct (kind,number) sorted by strength desc then lastIndex desc, max 10. `url`: fill from provider.urlTemplates when repoUrl + template exist (`{host}` needs host — pass host in via provider match context: extend input with `host: string|null`; substitute `{host}/{owner}/{repo}` — owner/repo split from repoUrl path, repo name minus trailing `.git`).
120
+ 8. Empty input → `{ provider: provider.name, issue: null, pr: null, allRefs: [] }`.
121
+
122
+ Test cases must include (synthetic but modeled on real observed sessions):
123
+ - assign claim beats later plain `issue view` of another number
124
+ - worktree path `issue-40-code-refs-badges` yields issue 40 claim with zero commands
125
+ - `gh pr create --body "…issue #40…"` sets both pr (pending → resolved by later assistant URL text) and issue 40 (pr-body claim)
126
+ - ambiguity case: commands viewing 439/440/441 each once, commenting 453 twice → issue = none from view (count<2), pr… construct exact expectation per rules (453 is a pr comment → pr=453 action)
127
+ - mention fallback: `#40` ×5, `#7` ×2 → issue 40 low confidence; `#40` ×3, `#41` ×3 → no winner (not 2×)
128
+ - unresolved `gh pr create` (no later URL) → pr null
129
+ - empty input; null host with generic fallback provider still extracts from URLs
130
+
131
+ - [ ] **Step 1: failing tests** (above list, one test each)
132
+ - [ ] **Step 2: run, fail**
133
+ - [ ] **Step 3: implement** (including the repo.mjs `gitRemoteUrl` refactor)
134
+ - [ ] **Step 4: tests pass; re-run task-1 tests**
135
+ - [ ] **Step 5: commit** `feat(code-refs): extraction engine with 4-tier signal scoring`
136
+
137
+ ---
138
+
139
+ ### Task 4: github.json artifact + store plumbing
140
+
141
+ **Files:**
142
+ - Create: `src/core/code-refs-store.mjs`
143
+ - Modify: `src/core/paths.mjs`, `src/core/types.mjs`, `src/core/store.mjs`
144
+ - Test: `test/code-refs-store.test.mjs`, extend `test/store.test.mjs`
145
+
146
+ **Interfaces:**
147
+ - `paths.mjs`: `providersPath(root) → <root>/providers.json`; `codeRefsPath(root, viewId) → <root>/views/<id>/github.json`
148
+ - `code-refs-store.mjs`:
149
+ - `emptyCodeRefsSnapshot({viewId}) → snapshot` `{ version:1, viewId, updatedAt, provider:null, issue:null, pr:null, allRefs:[] }`
150
+ - `normalizeCodeRefsSnapshot(raw, {viewId})` (same defensive shape as evidence's normalize)
151
+ - `readCodeRefs(root, viewId) → snapshot` / `writeCodeRefs(root, snapshot) → snapshot` (atomicWriteJson, bumps updatedAt)
152
+ - `summarizeCodeRefs(snapshot) → { provider, issue, pr, allRefs }`
153
+ - `updateCodeRefsFromEvidence(root, viewId, evidence) → boolean` — the hook helper: returns false without writing when `AGENT_BOARD_CODE_REFS=off`; reads meta via `readMeta` (lazy import cycle check — store.mjs must not import code-refs-store.mjs if code-refs-store imports store.mjs: therefore `updateCodeRefsFromEvidence` takes `meta` as a parameter instead; callers pass `row.meta`/config). Resolves repoRoot = `meta.repoRoot ?? meta.cwd`, host via `gitRemoteUrl`+parse, provider via `loadProviders(root)`+`matchProvider`, worktreePath/branch (`branch`: `git -C <cwd> branch --show-current`, best-effort cached 60s in module Map), builds engine input from `evidence.commands` + last 20 `assistantEvidence[].text`, writes snapshot only when serialized content changed. Never throws (catch → appendDiagnostic `code_refs_extract_failed`, return false).
154
+ - `types.mjs`: `CodeRefsSummary` typedef; `ViewState` optional `codeRefs`; Row typedef gains `codeRefs`.
155
+ - `store.mjs` `readViewArtifactSummaries`: add `codeRefs: summarizeCodeRefs(readCodeRefs(root, viewId))` (mirror evidence lines; archived short-circuit untouched).
156
+
157
+ - [ ] **Step 1: failing tests**: paths shape; snapshot normalize (garbage in → safe defaults); write→read roundtrip; summarize; updateCodeRefsFromEvidence with a fabricated evidence (commands containing `gh issue view 40` ×2) + fabricated meta (cwd = temp repo with github remote) → github.json contains issue 40 medium confidence; `AGENT_BOARD_CODE_REFS=off` → no file; broken providers.json in root → still extracts via builtins
158
+ - [ ] **Step 2: run, fail**
159
+ - [ ] **Step 3: implement**
160
+ - [ ] **Step 4: tests pass (incl. existing store.test.mjs)**
161
+ - [ ] **Step 5: commit** `feat(code-refs): per-view github.json artifact + store plumbing`
162
+
163
+ ---
164
+
165
+ ### Task 5: hook the five writeEvidence sites
166
+
167
+ **Files:**
168
+ - Modify: `runner/job-runner.mjs` (2 sites: initial write ~L66, shared `persist()` ~L105), `src/runtime/service.mjs` (syncRowEvent ~L519 and agent_end ~L550), `runner/state-runner.mjs` (~L57)
169
+ - Test: extend `test/service.test.mjs` (or the existing runner integration style) minimally; full coverage arrives in Task 7.
170
+
171
+ **Interfaces:**
172
+ - Consumes: `updateCodeRefsFromEvidence(root, viewId, evidence, meta)` from Task 4.
173
+ - Call it immediately after each `writeEvidence(root, evidence)`:
174
+ - job-runner sites have `config` (with cwd) but need meta → use `readMeta(root, viewId)` once at runner start, reuse
175
+ - service.mjs `syncRowEvent(row, event)` → pass `row.meta`
176
+ - state-runner has `config` → `readMeta(config.root, config.viewId)` once
177
+ - All call sites wrapped so a throw can never escape (helper already never throws; still call inside existing try blocks where present).
178
+
179
+ - [ ] **Step 1: failing test**: service-level — feed `syncRowEvent`-equivalent path (see how service.test.mjs fabricates rows) an event stream whose bash command is `gh issue comment 40 --body hi`; assert `<root>/views/<id>/github.json` exists with issue 40
180
+ - [ ] **Step 2: run, fail**
181
+ - [ ] **Step 3: implement the 5 hook calls**
182
+ - [ ] **Step 4: tests pass**
183
+ - [ ] **Step 5: commit** `feat(code-refs): extract on every evidence write (job-runner/service/state-runner)`
184
+
185
+ ---
186
+
187
+ ### Task 6: RowView badges + peek Refs section + README
188
+
189
+ **Files:**
190
+ - Modify: `src/core/rows.mjs` (rowView), `src/ui/dashboard.ts` (renderRow badges ~L1364-1365; renderPeek after Auto-state block ~L1400), `README.md` (env var table row)
191
+ - Test: `test/rows.test.mjs`, `test/dashboard-render.test.mjs`
192
+
193
+ **Interfaces:**
194
+ - RowView gains: `refsBadge: string` (e.g. `"#40 ▸#45"`, `""` when nothing), `refsLowConfidence: boolean` (true when the winning issue or pr confidence is `low`), `codeRefs: CodeRefsSummary|null` (peek consumes).
195
+ - Badge format: `${issuePrefix}${issue.number}` and `${prPrefix}${pr.number}` joined by space; prefixes from `summary.provider`'s bundle — simplest: store resolved prefixes in the snapshot at write time (add `issuePrefix`/`prPrefix` fields to snapshot in Task 4's normalize with defaults `#`/`▸#`; if you do this, update Task 4 tests — do it as part of this task's implementation and keep Task 4 commit green by amending its tests here).
196
+ - renderRow: append `refsBadge` to `statusBadges` string; when `refsLowConfidence` wrap badge in `t.fg("dim", …)` (compose with existing badge assembly; verify width math still clamps via existing `visibleWidth(badge)` path).
197
+ - renderPeek: after the Auto-state block add a `Refs` section (mirror that block's structure): provider name; one line per allRefs entry `kind #number · confidence · source · url`; section omitted when `codeRefs` is null/empty.
198
+ - README env table: add `AGENT_BOARD_CODE_REFS` row (`off` disables issue/PR badge extraction).
199
+
200
+ - [ ] **Step 1: failing tests**: rows.mjs rowView maps summary → badge strings (incl. dim flag, empty case); dashboard-render test asserting badge appears in the row line and Refs section renders in peek (follow existing dashboard-render.test.mjs patterns)
201
+ - [ ] **Step 2: run, fail**
202
+ - [ ] **Step 3: implement**
203
+ - [ ] **Step 4: tests pass**
204
+ - [ ] **Step 5: commit** `feat(dashboard): inline issue/PR badges + peek Refs section`
205
+
206
+ ---
207
+
208
+ ### Task 7: end-to-end integration + full verify
209
+
210
+ **Files:**
211
+ - Modify: `test-support/fake-pi.mjs` (new `FAKE_PI_MODE=github-refs`: emitted event stream includes a bash tool_execution for `gh issue edit 40 --add-assignee @me`, later an assistant message containing `https://github.com/zhuxixi/pi-agent-board/pull/45`), `test/runner.integration.test.mjs` (new case: run with that mode, assert `github.json` has issue 40 claim + pr 45 action, and row view badge `#40 ▸#45`)
212
+ - Test only.
213
+
214
+ - [ ] **Step 1: write the failing integration test** (mirror existing runner.integration.test.mjs setup: tmp AGENT_BOARD_ROOT etc.)
215
+ - [ ] **Step 2: extend fake-pi.mjs mode**, run test, iterate to green
216
+ - [ ] **Step 3: full `npm run verify`** (typecheck + tests + coverage + pack:dry) — all green; if coverage dips below gate, add focused unit tests to the new modules (do not weaken thresholds)
217
+ - [ ] **Step 4: commit** `test(code-refs): fake-pi github-refs mode + end-to-end badge assertion`
218
+
219
+ ---
220
+
221
+ ## Post-implementation (controller, not a task)
222
+
223
+ - Dispatch final broad code review, then open PR (`Closes #40`), label `zima:needs-review`, monitor CR per zima-pr-monitor skill.
@@ -0,0 +1,37 @@
1
+ # Plan: fix post-exit timing test failure (issue #46)
2
+
3
+ Branch: `issue-46-post-exit-timing` · Worktree: `.pi/worktrees/issue-46-post-exit-timing`
4
+ Spec: `docs/superpowers/specs/2026-08-30-post-exit-timing-fix-design.md`
5
+
6
+ ## Task 1 — persist() order in `runner/job-runner.mjs`
7
+ Move `updateCodeRefsFromEvidence(root, viewId, evidence, meta)` to **after**
8
+ `writeState(...)` inside the `persist()` closure (line ~100-108).
9
+ - Verify: `git diff` shows only the reordering in `persist()`.
10
+
11
+ ## Task 2 — manual-completion guards in the exit chain
12
+ 1. `applyHeuristicAutoState`: skip when state.json shows a manual completion
13
+ (`isManualCompletion(readState(...))` early-return before classification).
14
+ 2. Heuristic branch `persist(true)` → `persistUnlessManual(true)`.
15
+ 3. `drainQueuedFollowUp` and `finalizeSteeringIfNeeded`: early-return on
16
+ `isManualCompletion(readState(...))` (no follow-up run / no plan
17
+ resurrection over a manually completed row).
18
+ - Verify: `git diff` shows only these four call-site changes.
19
+
20
+ ## Task 3 — targeted test verification (Node 24)
21
+ ```bash
22
+ node --test --test-name-pattern "clobber a manual completion" test/runner.integration.test.mjs
23
+ ```
24
+ - Run 3×: all pass on the assertion part (364/377).
25
+ - Windows EPERM in the cleanup `finally` is acceptable (pre-existing env noise).
26
+
27
+ ## Task 4 — full test file + typecheck
28
+ ```bash
29
+ node --test test/runner.integration.test.mjs
30
+ npm run typecheck # if script exists
31
+ ```
32
+ - Failure set must not be worse than baseline (before fix: clobber + stopping-the-runner fail).
33
+
34
+ ## Task 5 — commit & summary
35
+ - Commit: `fix: prevent post-exit auto-state from clobbering manual completion (issue #46)`
36
+ - Note: the `stopping the runner finalizes the run as stopped` failure is
37
+ pre-existing (fails on `23c7c46` too) and out of scope.
@@ -0,0 +1,97 @@
1
+ # Spec: 修复 locks.mjs acquireLock 无眠死循环(issue #33)
2
+
3
+ > Draft 状态:待用户确认设计后进 worktree 实现(github-issue-driven 步 4 暂停点)。
4
+
5
+ ## 问题
6
+
7
+ `src/core/locks.mjs` `acquireLock` 在锁持续不可得时(根目录被删 / 只读 / 锁状态损坏),30s 等待窗过期后退化为零睡眠忙等循环:100% 单核 CPU、事件循环冻死、定时器全灭、进程永远不退出。现网两个 job-runner 僵尸进程分别空转 10.5 天 / 4.4 天(#33 现场证据)。
8
+
9
+ ## 根因(两处叠加)
10
+
11
+ ```js
12
+ while (true) {
13
+ try {
14
+ mkdirSync(lockPath);
15
+ writeFileSync(path.join(lockPath, "owner.json"), ...);
16
+ return;
17
+ } catch (err) {
18
+ // 缺陷 1:睡眠只在初始窗口内生效,窗口一过永不睡眠
19
+ if (!isLockStale(lockPath, staleMs) && Date.now() - started < Math.max(250, staleMs)) {
20
+ Atomics.wait(...20ms);
21
+ continue;
22
+ }
23
+ // 缺陷 2:releaseLock 静默吞错,循环无条件继续 → 无眠紧循环
24
+ releaseLock(lockPath);
25
+ }
26
+ }
27
+ ```
28
+
29
+ **触发机制(现场还原)**:teardown 的 `rmSync(root, {recursive})` 与 runner 收尾链在时间上系统性重叠(runner 快速收尾链 ~10-50ms 到达锁 vs 测试 waitFor 轮询 ~50ms + 断言后才 rmSync ~50-150ms)。目录树遍历删掉 `ensureDir` 刚验证过的父目录后,`mkdirSync` 从此永远 ENOENT(**ensureDir 只在循环外跑一次,循环内永不重建父目录**)→ 30s 窗口后零睡眠死循环。另两类等价失败:owner.json 写失败(半成品锁被误判 stale → 删了重建无限循环)、锁目录删不掉(rmSync 失败被吞)。
30
+
31
+ ## 设计决策
32
+
33
+ ### 决策表
34
+
35
+ | # | 决策点 | 选择 | 理由 |
36
+ |---|---|---|---|
37
+ | D1 | 强夺失败后的行为 | **有界尝试后抛错**(`Error: lock timeout: <path>`) | 锁不可得属环境故障,忙等无意义;抛错让调用层决定降级 |
38
+ | D2 | 强夺(窗口后偷锁)语义 | **保留**:窗口过期 → releaseLock → 立即重试一次 | 现有测试「fresh lock 等窗口后强夺」固化此语义,改动会破坏契约 |
39
+ | D3 | 重试上限 | 等待窗内无限重试(带睡眠,窗口 = `max(250, staleMs)`,保留现有 floor);窗口后**最多 2 次强夺**(含 owner.json 写失败路径),仍失败即抛 | 覆盖 rm 失败 / mkdir 仍失败 / 写失败三类;有界即无死循环 |
40
+ | D4 | 睡眠策略 | 保留 `Atomics.wait(20ms)`;循环内任何 continue 前必有睡眠或已抛错 | 反证 D1 的失败模式,杜绝任何无眠路径 |
41
+ | D4b | **循环内自愈**:每次 catch 后重跑 `ensureDir(dirname)`(ensureDir 自身失败计为一次失败尝试) | 现场最高频竞态(teardown rmSync 删掉父目录)从「等窗口后抛错」升级为「瞬时自愈、正常拿锁退出」;有界性不变 |
42
+ | D5 | fs 注入 | 仿 `screen-log.mjs` `defaultScreenLogFs` 先例,加 `locksFs` 参数(默认 node:fs) | 现有测试无注入,注入后才能确定性复现「mkdir 永败」等场景 |
43
+ | D6 | 队列层错误传播 | follow-up-queue.mjs 5 个入口 try/catch **catch-all**(含 fn 内 writeFollowUpQueue 的 fs 错误,非仅锁错误)→ `{ok:false, error}` | 保持 {ok} 返回值约定,service.mjs 无需改动;已确认 follow-up-queue.test.mjs 无 throw 断言,catch-all 安全 |
44
+ | D7 | job-runner 兜底 | `.finally` 链里 `finalizeSteeringIfNeeded` + `drainQueuedFollowUp` 各自 try/catch | 锁层抛错永远不会阻止 `process.exit`——僵尸进程防线最后一道 |
45
+ | D8 | 默认 staleMs | 不变(30s) | 现有测试与调用方依赖 |
46
+ | D9 | 测试 harness 清理 | 全部 7 处 launchRun 都捕获 pid(现有 4 处丢弃,含出过僵尸的 dash 测试)→ finally 里 TERM → 短等待 → KILL → 再 rmSync(root) | 现场两个僵尸的直接源头是 teardown 只删目录不杀 detached runner;这层保证测试不再产出孤儿(launch.test.mjs 的 detached fake-pi 已确认自然退出,无需处理) |
47
+
48
+ ### 数据流(修复后)
49
+
50
+ ```
51
+ claimNextFollowUp(root, viewId)
52
+ → withViewLockSync(root, viewId, "queue", fn)
53
+ → acquireLock: [wait loop w/ sleep] → 窗口过期 → steal attempt ×2 → 失败 → throw
54
+ → catch → { ok: false, error: "follow-up queue lock unavailable: ..." }
55
+ → job-runner drainQueuedFollowUp: claimNextFollowUp 返回 {ok:false} → 直接 return(不进 launch)
56
+ → .finally → process.exit 必达
57
+ ```
58
+
59
+ ### 组件契约
60
+
61
+ - `withFileLockSync` / `withViewLockSync`:成功返回 fn 结果;**新行为**——锁超时抛 `Error`(message 含 lockPath 与耗时)。
62
+ - follow-up-queue 5 个导出(enqueue/claim/complete/release/remove/clear):任何锁失败 → `{ok:false, error}`,不再抛出。
63
+ - service.mjs:零改动(已按 {ok} 消费)。
64
+ - job-runner.mjs:收尾链不因锁失败挂起。
65
+
66
+ ### 降级行为
67
+
68
+ - 锁失败时队列操作静默失败并写 diagnostics(job-runner 用 appendDiagnostic;service 层已有该模式),用户可感知但系统不挂。
69
+ - 不引入锁重试队列、不引入跨进程 watchdog——超出本 issue 范围。
70
+
71
+ ## 非目标
72
+
73
+ - 不改锁的 mkdir 实现(不换 flock/其他机制)
74
+ - 不处理「锁持有者崩溃残留」之外的竞争语义
75
+ - 不改 30s staleMs 默认值
76
+ - 不引入异步锁
77
+
78
+ ## 测试计划(red-green)
79
+
80
+ test/locks.test.mjs 现有 5 测试全绿;新增(全部带 `{ timeout: 5000 }` 防挂):
81
+
82
+ 1. **mkdir 永败**(注入 fs):`withFileLockSync(..., { staleMs: 50 })` 在 ~250ms 窗口(`max(250, staleMs)` floor)后抛错,不在 5s 内挂起。
83
+ 2. **写 owner.json 永败**(注入 fs):mkdir 成功但 writeFileSync 抛 → 有界强夺后抛错。
84
+ 3. **rmSync 永败**(注入 fs):窗口后强夺 rm 失败 → 抛错。
85
+ 4. **争用正常恢复**:注入 fs 模拟「前 N 次 mkdir EEXIST、之后成功」→ 等待窗内获取成功(保语义 1)。
86
+ 4b. **父目录被删后自愈**(D4b):真实 fs,锁获取前删掉父目录 → ensureDir 在循环内重建 → 正常拿锁(验证现场最高频竞态透明自愈)。
87
+ 5. **窗口后强夺仍成功**:复用现有测试 4(不回归)。
88
+ 6. follow-up-queue 层:锁失败 → `{ok:false, error}`(用注入 fs 或真实坏路径)。
89
+ 7. job-runner 收尾:若可低成本导出/集成测试则覆盖「锁坏时 drainQueuedFollowUp 不挂起、process 退出」;否则以代码评审 + 手动验证为准。
90
+ 8. 集成测试 teardown:runner 被杀后进程表里不再残留 job-runner(现有集成测试全绿即可证明 kill 生效)。
91
+
92
+ ## 验收
93
+
94
+ - `npm test`(或 `npm run verify`)全绿
95
+ - 35s 复现脚本(/sys 只读路径)修复后应立即抛错而非空转
96
+ - 跑完集成测试后 `ps` 无残留 job-runner
97
+ - 代码评审通过后 PR + Zima CR
@@ -0,0 +1,35 @@
1
+ # Issue #34 — CI flaky: pty-runner integration test times out waiting for pre-connect output
2
+
3
+ ## Root cause
4
+
5
+ `test/pty-runner.integration.test.mjs` (test "pty-runner creates host socket, broadcasts output, forwards input, finalizes") waits for the boot banner `fake pi ready` **over the control socket**. But the socket only carries *live* output: `broadcast()` iterates `clients`, which is empty until a client connects. If the child emits `fake pi ready` before the test's socket connects (CI runners start the child fast), the output is broadcast to zero clients and lost — the 3s `waitFor` times out.
6
+
7
+ History output is intentionally NOT replayed over the socket; the UI attach path replays it from the screen log file (`src/ui/pty-attach.ts` `replayScreenLog`). The test's assumption contradicts the protocol design.
8
+
9
+ ### Evidence
10
+
11
+ - CI run 32554867604 (main, #32): `not ok 181 ... error: 'timed out waiting'` at `test/pty-runner.integration.test.mjs:65`, both Node 22 and Node 24.
12
+ - PR branch run 32554619699 failed on a *different* test (`runner.integration.test.mjs:195`, auto-done idle), passed on rerun — separate timing-sensitive spot, out of scope.
13
+ - Reproduced deterministically locally with a forced "output before connect" script: 3/3 timeouts. Same test passes 5/5 under normal timing.
14
+ - Local runs after fix: 6/6 pass; late-connect scenario: 3/3 pass; full suite: 313/313 pass.
15
+
16
+ ## Fix design
17
+
18
+ Only the test changes; no product code change (socket protocol behavior is by design).
19
+
20
+ | Step | File | Change |
21
+ |------|------|--------|
22
+ | 1 | `test/pty-runner.integration.test.mjs` | Replace the socket wait for `fake pi ready` with a screen-log read wait (`P.screenLogPath(root, "v1")` contains `fake pi ready`) — mirrors UI attach replay semantics |
23
+
24
+ Assertions that remain on the socket (post-connect realtime events, timing-safe):
25
+ - `echo:hello` output (live broadcast + input forwarding)
26
+ - resize → `readHost().cols === 100`
27
+ - `exit` → `endedAt` set, state `exited`
28
+
29
+ Screen-log assertions (file-based, timing-safe):
30
+ - boot banner `fake pi ready` present (already asserted at test end via `assert.match`)
31
+
32
+ ## Non-goals
33
+
34
+ - No change to `runner/pty-runner.mjs` socket protocol.
35
+ - No change to `runner.integration.test.mjs` flaky spot (tracked separately if it recurs).
@@ -0,0 +1,128 @@
1
+ # Spec: 行内展示 session 关联的 issue / PR 编号(code-refs)
2
+
3
+ - Issue: https://github.com/zhuxixi/pi-agent-board/issues/40
4
+ - 日期:2026-08-29
5
+ - 状态:待用户确认
6
+
7
+ ## 1. 背景与目标
8
+
9
+ Dashboard 每行(一个后台 pi session)目前只有 name + summary + age,看不到「这行在处理哪个 issue、它提交了哪个 PR」。本特性在行内徽章区展示这两个编号,peek 视图给完整信息。
10
+
11
+ **平台无关是硬约束**:GitHub、GitLab、公司内网代码平台都有 issue/PR(MR)概念但 CLI 与 URL 不同。平台差异全部做成正则规则数据,提取引擎保持通用。
12
+
13
+ ### 目标(Goals)
14
+
15
+ 1. 行内徽章显示最近一个 issue 编号 + 最近一个 PR 编号(如 `#40 ▸#45`),前缀随平台规则(GitLab MR 用 `!`)。
16
+ 2. peek 视图显示完整引用列表(编号 + 置信度 + 平台链接)。
17
+ 3. 平台规则可由用户配置扩展(内网平台 = 加一段 JSON,引擎零改动)。
18
+ 4. 纯本地提取,零网络调用;实时性 = 事件驱动(session 跑到相关命令时徽章即出现)。
19
+
20
+ ### 非目标(Non-goals)
21
+
22
+ - 不做网络补全(`gh pr list --head` 查分支对应 PR、抓取 issue/PR 标题与状态)——列为 v2 候选,本期不做。
23
+ - 不做 PR 状态着色(open/merged)、不做 `refs:has` 过滤器——v2 候选。
24
+ - 不修 `outputPreview` 的 `[object Object]` bug——拆独立 issue #41。
25
+ - 不识别「无编号」的平台对象(如纯分支名)。
26
+
27
+ ## 2. 决策表(已与用户对齐)
28
+
29
+ | # | 决策点 | 结论 |
30
+ |---|---|---|
31
+ | D1 | 评分规则 | 信号分四级(认领/worktree 命名/PR 回链 = 最强;动作 = 强;查看 = 中;裸引用 = 弱兜底),平局时最近的最强信号赢 |
32
+ | D2 | 多候选显示 | 行内只显示最近一个 issue + 一个 PR;完整列表进 peek |
33
+ | D3 | 用户规则与内置同名 provider 冲突 | 按规则追加(用户规则优先匹配,其后是内置规则) |
34
+ | D4 | outputPreview bug | 拆独立 issue #41,本特性不含 |
35
+ | D5 | 隔离测试 | `PI_CODING_AGENT_DIR` + `AGENT_BOARD_ROOT` 双变量隔离,不动 `~/.pi`;软链开发方式不用 |
36
+
37
+ ## 3. 架构
38
+
39
+ 复刻仓库既有「artifact → summarize → 合并进 Row → renderRow 徽章」模式(evidence/diagnostics/followUps/steering 同构)。新增一个纯函数引擎模块、一个 per-view artifact、五处写入钩子、两处渲染改动。
40
+
41
+ ```
42
+ 事件流 → reduceEvidence → evidence.json ──┐
43
+ ├─→ extractCodeRefs() → github.json → Row.github → 徽章/peek
44
+ providers.json(用户规则)+ 内置规则 ──────┘ ▲
45
+ repo.mjs remoteHost()(带缓存)───────────────────┘
46
+ ```
47
+
48
+ ### 组件契约
49
+
50
+ **C1 `src/core/code-refs.mjs`(新,纯函数,零 I/O,主测试对象)**
51
+
52
+ - `extractCodeRefs(input, providerSet) → CodeRefsResult`
53
+ - `input`: `{ commands: EvidenceCommand[], assistantTexts: string[], worktreePath: string|null, branch: string|null }`
54
+ - `providerSet`: 解析后的规则包列表(已按 host 选好 + 用户规则已合并)
55
+ - 输出: `{ issue: {number, confidence, source} | null, pr: {number, confidence, source} | null, repoUrl: string|null }`
56
+ - `loadProviders(builtIns, userConfig) → provider 列表`(实现 D3 追加语义:同名 provider 时用户规则排在内置规则前面)
57
+ - `matchProvider(providers, remoteHost) → provider | null`(host 匹配;无匹配返回 null,调用方走兜底规则)
58
+ - 信号强度枚举:`claim > action > view > mention`,每级带置信度 high/medium/low。
59
+
60
+ **C2 信号来源与评分规则(D1 落地)**
61
+
62
+ | 强度 | 信号 | 检测方式 |
63
+ |---|---|---|
64
+ | claim(最强) | 认领命令:provider 规则里 `strength: "claim"` 的命令模式(GitHub 内置:`gh issue edit N --add-assignee`) | commands 正则 |
65
+ | claim | worktree 命名:`issue-<N>-<slug>`(issue-driven 工作流强制规范) | worktreePath / branch 结构化解析(非正则配置,引擎内置) |
66
+ | claim | PR 回链:`gh pr create` 的 body/后续文本中的 `issue #N` / `Closes #N`(同时定 issue + PR 两个值) | commands + assistantTexts |
67
+ | action(强) | `issue comment/edit/close N`、`pr checkout/view/merge N`、`pr create`(编号从 outputUrl 或后续 URL 反查,见 D4 限制) | commands 正则 |
68
+ | view(中) | `issue view N` / `pr view N` | commands 正则,要求频次 ≥2,取最近一次 |
69
+ | mention(弱,兜底) | 裸 `#N` | assistantTexts,要求频次显著最高(≥3 且 ≥ 第二名的 2 倍) |
70
+
71
+ 平局:命令序列中位置最靠后的最高强度信号赢(commands 有序,按数组下标比较,不用时间戳)。置信度:claim/action → high;view → medium;mention → low。渲染时 low 用 dim 色(宁可不显示也不错显示——mention 级仅在无任何更强信号时出现)。
72
+
73
+ **C3 `providers.json` 规则 schema(用户配置)**
74
+
75
+ - 位置:`$AGENT_BOARD_ROOT/providers.json`(随 store root 隔离,E2E 天然不污染真实配置)。
76
+ - 结构:`{ providers: [{ name, hosts[], issuePrefix, prPrefix, urlTemplates: { issue, pr }, rules[] }] }`;每条 rule = `{ pattern, kind: "issue"|"pr", strength: "claim"|"action"|"view", numberFrom?: "capture"|"outputUrl" }`。urlTemplates 用占位符拼链接,如 `"https://{host}/{owner}/{repo}/-/issues/{number}"`(owner/repo 从 remote URL 解析)。
77
+ - 内置默认:GitHub + GitLab 两份(含 hosts、URL 正则、CLI 正则、前后缀、链接模板)。
78
+ - 加载失败(JSON 语法错 / 单条正则非法):跳过该条并记 diagnostics(`code_refs_config` 码),不炸 dashboard。
79
+
80
+ **C4 `repo.mjs` 增补**
81
+
82
+ - `gitRemoteHost(repoRoot) → string|null`:`git remote get-url origin` 解析 host,支持 ssh(`git@host:path`)与 https 两种形式;结果按 repoRoot 缓存在模块级 Map(一个仓库只查一次,失败也缓存 null)。
83
+
84
+ **C5 artifact `github.json`(per-view)**
85
+
86
+ - `paths.mjs` 加 `codeRefsPath(root, viewId)` → `views/<id>/github.json`。
87
+ - 内容:`{ version: 1, viewId, updatedAt, provider: string|null, issue: {...}|null, pr: {...}|null, allRefs: [...](peek 用,最多 10 条) }`。
88
+ - 读写走 `atomicWriteJson`(并发写者多,KB 已有教训)。
89
+ - `readViewArtifactSummaries` 增加 `codeRefs:` 汇总;`Row`/`RowView` 加 `codeRefs` 字段。
90
+
91
+ **C6 写入钩子(5 处,`writeEvidence` 的全部调用点)**
92
+
93
+ `runner/job-runner.mjs` ×2(共用 persist())、`src/runtime/service.mjs` ×2、`runner/state-runner.mjs` ×1。统一收敛为一个 helper:`updateCodeRefsFromEvidence(root, viewId, evidence, meta)`——增量不重算:引擎输入只取 evidence 的 commands + 最近若干条 assistantTexts + meta.worktreePath/branch,纯正则,实测成本微秒级;每次 evidence 写入后顺带调用。失败只记 diagnostics,不影响主流程。
94
+
95
+ **C7 渲染**
96
+
97
+ - `rows.mjs` `rowView`:透传 `codeRefs`。
98
+ - `dashboard.ts` `renderRow`:statusBadges 追加 `issuePrefix+number`(issue)与 `prPrefix+number`(PR),low 置信度用 `dim` 色;宽度沿用现有「从 name 预算扣」机制。
99
+ - peek 视图:新增 "Refs" 段(复刻 Auto-state 段模式):provider 名、issue/PR 编号 + 置信度 + 由 `urlTemplate` 拼出的终端超链接、allRefs 完整列表。
100
+
101
+ ## 4. 错误处理与降级
102
+
103
+ - 无 git 仓库 / 无 remote / host 不认识 → 只用「通用 URL 兜底规则」(匹配任意 host 的 `/issues/N`、`/pull/N`、`/-/issues/N`、`/-/merge_requests/N`)+ worktree 命名解析;都没有则不显示徽章。
104
+ - 用户 providers.json 损坏 → 内置规则仍生效,diagnostics 记一条 warn。
105
+ - 提取过程任何异常 → catch 后记 diagnostics,evidence 主流程不受影响(与既有 artifact 容错一致)。
106
+ - 无任何引用 → `github.json` 写空结果(`issue: null, pr: null`),渲染跳过徽章,不留 stale 数据。
107
+
108
+ ## 5. 测试策略(四层,详见 issue 评论)
109
+
110
+ 1. **单元**:`code-refs.mjs` 全分支覆盖——每级信号命中、强度排序、平局规则、mention 兜底阈值、provider 追加合并(D3)、host 匹配、损坏配置容错。
111
+ 2. **真实数据回测**:脱敏后的真实 evidence.json 命令序列做 fixture(`moc 439` 行的多引用歧义场景是核心用例)。
112
+ 3. **集成**:fake-pi.mjs 注入含 `gh issue edit 40 --add-assignee` / `gh pr create` 的事件流 → 断言 `github.json` 内容与渲染徽章字符串。
113
+ 4. **手工 E2E**:`PI_CODING_AGENT_DIR` + `AGENT_BOARD_ROOT` 隔离环境;scratch 仓库换 remote host 验证 provider 匹配;PR 编号用 `echo <url>` 模拟(全程零真实 GitHub 变更)。
114
+ 5. 验收:`npm run verify` 全绿(typecheck + test + c8 行 85%/分支 70% + pack dry-run)。
115
+
116
+ ## 6. 分阶段
117
+
118
+ - **v1(本 issue)**:C1–C7 全部(纯本地提取 + 渲染 + 配置)。
119
+ - **v2(另开 issue,不在本期)**:网络补全(`pr list --head`、标题/状态)、PR 状态着色、`refs:has` 过滤器、outputPreview 修复后的 `outputUrl` 反查增强(依赖 #41)。
120
+
121
+ ## 7. 实现顺序(供 plan 参考)
122
+
123
+ 1. `repo.mjs` `gitRemoteHost` + 缓存(含单测)
124
+ 2. `code-refs.mjs` 引擎 + 内置规则 + schema 校验(含单测,覆盖率大头)
125
+ 3. `github.json` artifact 读写 + `readViewArtifactSummaries` 汇总 + Row/RowView 字段
126
+ 4. 5 处写入钩子
127
+ 5. 渲染:徽章 + peek Refs 段
128
+ 6. 四层测试补齐 + `npm run verify`
@@ -0,0 +1,77 @@
1
+ # Issue #48 根因报告 + 修复 spec
2
+
3
+ Windows: pty-runner dies with uncaught EPERM when host.json atomicWrite races a reader; attach view stuck in reconnect loop
4
+
5
+ 状态:**草稿,待用户确认**(2026-08-30)
6
+
7
+ ---
8
+
9
+ ## 1. 根因(已代码层验证,非推测)
10
+
11
+ ### 机制(Windows 特有)
12
+ libuv 打开文件默认共享模式不含 `FILE_SHARE_DELETE`;Node `renameSync` 在 Windows 映射为 MoveFileExW(MOVEFILE_REPLACE_EXISTING),替换已存在目标需先删除旧目标,而删除要求所有持句柄进程带 FILE_SHARE_DELETE——否则抛 `EPERM: operation not permitted`(本机 100/100 复现,见 issue 正文复现代码)。
13
+
14
+ ### 触发链
15
+ 1. `runner/pty-runner.mjs` 1s 心跳 `update()` → `persist()` → `writeHost()` → `atomicWriteJson()`(`src/core/atomic.mjs`:写 `.tmp` → `renameSync`,**无重试**)
16
+ 2. service 渲染路径高频 `readHost()/loadRow()`(`readFileSync` host.json)——密集工具调用 + 任意面板渲染即构成 reader
17
+ 3. 窗口重叠 → renameSync 抛 EPERM → **无 try/catch**(update/persist 均无防护,全文件无 uncaughtException 兜底)→ runner 以 `detached: true, stdio: "ignore"`(`launch.mjs`)静默死亡,留 `.tmp` 残留
18
+ 4. 连锁:runner 死 → ConPTY 断 → 托管 child pi 随死
19
+ 5. attach 视图:只有 socket `{type:"exit"}` 消息才置 `status="host exited"`(`pty-attach.ts` onSocketData L871);崩溃 runner 不发 → `scheduleReconnect()` 150ms **无限重连**
20
+ 6. 逃生失败:`handleInput` 中 `←`/`ctrl+]` 仅当 `childInputLooksEmpty()` 才 detach(崩溃画面停在非空输出行,不满足);`send()`(L852)`!connected` 时**静默丢弃**;pi-tui 无系统级兜底键
21
+
22
+ ### 次要发现
23
+ - `failEarly()` 硬编码 `/tmp/pi-agent-board-pty-runner.err`(Windows 无 /tmp,写失败被吞)——崩溃零痕迹的原因之一
24
+ - 二次崩溃无 `host_reconciled` 诊断(reconcile 只在 panel open / session_start 跑)→ idle 行掩盖崩溃
25
+
26
+ ## 2. 修复设计(四层,L1-L3 必做,L4 待确认)
27
+
28
+ ### L1 `src/core/atomic.mjs` — rename 重试(根因层)
29
+ - 新增内部 `renameWithRetry(tmp, file, opts?)`:
30
+ - 错误码白名单重试:`EPERM` / `EBUSY` / `EACCES`(Windows 共享冲突三兄弟)
31
+ - 3 次重试 + 退避 10ms → 50ms → 250ms(同步调用,上限阻塞 ~310ms,可接受)
32
+ - 重试无需重写 tmp(写文件已在 rename 前完成,tmp 内容完整)
33
+ - 全部失败:`unlinkSync(tmp)`(try/catch 清理残留)→ 抛**原错误**(保持调用方语义,由 L2 降级)
34
+ - `atomicWrite` 调 `renameWithRetry`;导出 `renameWithRetry` 供单测注入失败回调
35
+
36
+ ### L2 `runner/pty-runner.mjs` — 持久化防御 + 崩溃兜底(防御层)
37
+ - `persist()` 包 try/catch:失败时 `appendDiagnostic(root, viewId, { type: "persist_error", ... })`(`core/diagnostics.mjs` 仅依赖 atomic/paths,runner 可安全导入)→ 降级继续,下个心跳 tick 再试,**不杀进程**
38
+ - `update()`:persist 失败不影响 `broadcast()`(socket 消息是 attach 主通道,host.json 短暂陈旧可接受)
39
+ - `process.on("uncaughtException")` 一次性兜底:
40
+ 1. 移除 handler(防循环)
41
+ 2. appendDiagnostic 记录
42
+ 3. 尽力最终 persist:`state:"failed", error, endedAt`(try/catch 包住)
43
+ 4. `broadcast({ type: "exit", exitCode: 1 })` ← **关键**:让已连 attach 正常退出,不再无限重连
44
+ 5. `process.exit(1)`
45
+ - `failEarly` 的 `/tmp` 硬编码 → `os.tmpdir()`(Windows 兼容小修)
46
+
47
+ ### L3 `src/ui/pty-attach.ts` — 逃生键 + 重连超时(UI 层)
48
+ - **逃生**:DETACH 分支条件改为 `if (!this.connected || this.childInputLooksEmpty()) this.detach()` —— 断连/启动中随时可按 `←`/`ctrl+]` 退出(`send({type:"detach"})` 在 !connected 时被丢弃,无害)
49
+ - **重连超时**(新增 `everConnected` 标志,首个 socket connect 成功置位):
50
+ - `everConnected === true` 断开后:15s 重连窗口 → 超时置 `status = "host exited"`、停止重连、保持可 detach(覆盖 issue 主场景:host 已崩)
51
+ - `everConnected === false`(host 冷启动中,service 在 attach 前已 launchHost):保留无限重连 + 宽松上限 120s → 超时置 `status = "host not reachable"` 停止重连(覆盖 launchHost 失败场景)
52
+ - 超时到期不自动 `done()`,显示错误状态等用户按 `←` 退出(避免突然弹走)
53
+
54
+ ### L4(可选,待确认)— idle 行掩盖崩溃的展示级检测
55
+ - 落点:`loadRow()` 已派生 `hostAlive`;dashboard 渲染时对 `host.state==="alive" && !hostAlive && lastSeenAt 陈旧(>10s)` 的行显示 host-lost 标记(**仅展示级,不改 state.json**,避免与 resume/重启中误判)
56
+ - 不做 state 级 reconcile 触发时机修改(范围外)
57
+
58
+ ## 3. 测试计划(TDD 先行)
59
+
60
+ | 层 | 测试 | 方式 |
61
+ |---|---|---|
62
+ | L1 | `renameWithRetry` 重试次数/退避/白名单/最终抛错/失败后 tmp 清理/happy path | 单测注入失败回调(新增 `test/atomic-retry.test.mjs`) |
63
+ | L2 | persist 持续失败 → runner 不崩溃 + diagnostics 有记录 + host.json 陈旧但不死 | 集成:host.json 目标路径被同名**目录**占位(rename 必失败,跨平台稳定触发) |
64
+ | L2 | 崩溃兜底 `finalizeCrash`:写 failed 状态 + broadcast exit | 抽纯函数单测(外部无法稳定注入 uncaughtException) |
65
+ | L3 | handleInput 逃生分支(!connected 时 detach) | 现有 pty-attach render 测试基建(mock tui/theme/term) |
66
+ | L3 | 重连超时状态机(everConnected × 超时 × 状态文案) | 抽纯函数或组件级测试 |
67
+
68
+ 回归:全量 `node --test test/*.test.mjs` + `npm run typecheck`(Windows 全量已知 6 个既有失败,基线对比排除回归)。
69
+
70
+ ## 4. 非目标
71
+ - 不做 unlink-then-rename 兜底(短暂缺失窗口影响并发 reader;重试已覆盖)
72
+ - 不改 reconcile 触发时机(L4 仅展示级)
73
+ - 不做历史 `.tmp` 残留 GC(screen-log-gc 范围外,可后续单列)
74
+
75
+ ## 5. 交付
76
+ - worktree:`issue-48-eprm-atomicwrite-race`(spec 批准后)
77
+ - PR 标题:`fix: harden host.json persistence against Windows EPERM rename races (issue #48)`,覆盖 L1-L3(L4 视确认)