@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,80 @@
1
+ # Spec: fix deterministic post-exit test failure after #43 (issue #46)
2
+
3
+ ## Problem
4
+
5
+ `test/runner.integration.test.mjs` → `runner does not clobber a manual completion
6
+ made during post-exit model passes` fails deterministically on Node 24 since
7
+ #43 (`ffc2c8c`). Upstream `main` CI is red (`e14cc41`, `ffc2c8c`) and blocks
8
+ subsequent PRs (#44).
9
+
10
+ ## Root cause (research: `~/.claude/github-issue-driven/zhuxixi/pi-agent-board/issue-46/`)
11
+
12
+ #43 added a synchronous `updateCodeRefsFromEvidence()` call (git subprocesses,
13
+ hundreds of ms) into `runner/job-runner.mjs`'s `persist()` chain, widening two
14
+ pre-existing race windows:
15
+
16
+ ### Window 1 — `markCompleted` rejected (CI failure point)
17
+ `persist()` order: `writeStatus` (endedAt visible) → `updateCodeRefsFromEvidence`
18
+ (slow) → `writeState` (semanticState converges). The test sees `endedAt` while
19
+ `state.json` is still `working`; the runner process is still alive
20
+ (`pid.json` records the runner pid) → `isAgentBusy(row)` → `markCompleted`
21
+ returns `'Wait for the active run to finish before marking done'`.
22
+
23
+ ### Window 2 — manual completion clobbered (the actual bug)
24
+ `finalizeSemanticState` (`src/core/derive.mjs:33`) returns `"idle"` for a clean
25
+ worker exit. `applyHeuristicAutoState` (`runner/job-runner.mjs:360`) calls
26
+ `applyAutoStateToStatus` with the **in-memory** status; `isManualCompletion`
27
+ requires `semanticState === "completed"` (it is `"idle"`), so the guard does not
28
+ fire and the post-exit heuristic classification (`in_progress`, since
29
+ `autoStateDoneDisabled()` defaults to true) overwrites the manual completion —
30
+ `state.json` becomes `idle` + `autoState: {kind: "in_progress"}`.
31
+ `maybeModelAutoState` has a fresh-read guard for this exact case;
32
+ `applyHeuristicAutoState` does not.
33
+
34
+ ## Fix (minimal, four changes in `runner/job-runner.mjs`)
35
+
36
+ ### Change 1 — persist order
37
+ Move `writeState` before `updateCodeRefsFromEvidence` inside `persist()`:
38
+ ```js
39
+ writeStatus(root, status);
40
+ writeRunEvidence(root, evidence);
41
+ writeEvidence(root, evidence);
42
+ writeState(root, projectViewState(status, now, readState(root, viewId)));
43
+ updateCodeRefsFromEvidence(root, viewId, evidence, meta);
44
+ ```
45
+ Semantics unchanged (code-refs extraction depends only on evidence + git).
46
+ Verified experimentally: markCompleted assertion passes again.
47
+
48
+ ### Change 2 — heuristic persist goes through persistUnlessManual
49
+ In the close handler, the `applyHeuristicAutoState` branch's `persist(true)`
50
+ becomes `persistUnlessManual(true)` so a manual completion racing the
51
+ heuristic classification is not overwritten. `persistUnlessManual` checks
52
+ `isManualCompletion(readState(...))` (state.json — the only place
53
+ `completeView` writes the `completed` + `autoState: null` signal; it only
54
+ clears autoState in status.json) before writing.
55
+
56
+ ### Change 3 — applyHeuristicAutoState manual-completion guard
57
+ Skip classification when state.json shows a manual completion, so the
58
+ in-memory status isn't mutated in a way a later drain/persist could replay
59
+ over the user's verdict:
60
+ ```js
61
+ const latestState = readState(config.root, config.viewId);
62
+ if (isManualCompletion(latestState)) return false;
63
+ ```
64
+
65
+ ### Change 4 — drainQueuedFollowUp / finalizeSteeringIfNeeded guards
66
+ `drainQueuedFollowUp` (follow-up runs) and `finalizeSteeringIfNeeded`
67
+ (plan approval resurrection) both early-return on
68
+ `isManualCompletion(readState(...))` so the exit chain never starts new
69
+ work or resurrects a row the user just manually completed.
70
+
71
+ ## Non-goals
72
+ - No changes to #44/#45 code (windowsHide / control socket)
73
+ - No handling of Windows-local EPERM cleanup noise (environment-only)
74
+ - No auto-state state machine refactor
75
+
76
+ ## Verification
77
+ 1. clobber test ≥3× on Node 24: all pass (assertion part)
78
+ 2. Full `test/runner.integration.test.mjs`: failure set not worse than baseline
79
+ 3. `npm run typecheck` if present
80
+ 4. CI green (Node 22/24) after merge
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhuxixi/pi-agent-board",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
4
4
  "description": "Agent-board dashboard for Pi: dispatch, monitor, peek/reply, and attach to background Pi sessions.",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -53,8 +53,9 @@
53
53
  "postinstall": "node scripts/patch-vulns.mjs",
54
54
  "typecheck": "tsc --noEmit",
55
55
  "test": "node --test test/*.test.mjs",
56
+ "test:coverage": "c8 node --test test/*.test.mjs",
56
57
  "pack:dry": "npm pack --dry-run",
57
- "verify": "npm run typecheck && npm test && npm run pack:dry"
58
+ "verify": "npm run typecheck && npm test && npm run test:coverage && npm run pack:dry"
58
59
  },
59
60
  "peerDependencies": {
60
61
  "@earendil-works/pi-coding-agent": "*",
@@ -72,6 +73,7 @@
72
73
  "@earendil-works/pi-coding-agent": "0.79.8",
73
74
  "@earendil-works/pi-tui": "0.79.8",
74
75
  "@types/node": "^25.9.1",
76
+ "c8": "^12.0.0",
75
77
  "typescript": "^5.9.3"
76
78
  },
77
79
  "dependencies": {
@@ -16,11 +16,12 @@ import { encodePromptForCliArg } from "../src/core/prompt-transport.mjs";
16
16
  import { applyAutoStateToStatus, autoStateEnabled, autoStateFromModelOrHeuristic, autoStateModel, buildAutoStatePrompt, heuristicAutoState, isManualCompletion } from "../src/core/auto-state.mjs";
17
17
  import { appendDiagnostic } from "../src/core/diagnostics.mjs";
18
18
  import { emptyEvidenceSnapshot, finalizeEvidence, reduceEvidence, summarizeEvidence, writeEvidence, writeRunEvidence } from "../src/core/evidence.mjs";
19
+ import { updateCodeRefsFromEvidence } from "../src/core/code-refs-store.mjs";
19
20
  import { claimNextFollowUp, completeFollowUp, releaseFollowUp } from "../src/core/follow-up-queue.mjs";
20
21
  import { newRunId } from "../src/core/ids.mjs";
21
22
  import { launchRun } from "../src/core/launch.mjs";
22
23
  import * as P from "../src/core/paths.mjs";
23
- import { readState, readStatus, writeState, writeStatus } from "../src/core/store.mjs";
24
+ import { readState, readStatus, readMeta, writeState, writeStatus } from "../src/core/store.mjs";
24
25
  import { readSteering, recordPlanReady } from "../src/core/steering.mjs";
25
26
  import { buildApprovePlanPrompt, buildPlanChangesPrompt, buildPlanRequestPrompt } from "../src/core/steering-prompts.mjs";
26
27
 
@@ -54,12 +55,16 @@ function main() {
54
55
  const stderrLog = P.stderrPath(root, viewId, runId);
55
56
  const eventsLog = P.eventsPath(root, viewId, runId);
56
57
 
58
+ // Read the view meta once so code-ref extraction reuses it across every evidence write.
59
+ const meta = readMeta(root, viewId);
60
+
57
61
  let status = createRunStatus(config, null, Date.now());
58
62
  let evidence = emptyEvidenceSnapshot({ viewId, runId, source: "json-runner" });
59
63
  appendDiagnostic(root, viewId, { source: "runner", runId, code: "runner_start", message: "Runner started", details: { kind: config.kind, cwd: config.cwd, model: config.model } });
60
64
  writeStatus(root, status);
61
65
  writeRunEvidence(root, evidence);
62
66
  writeEvidence(root, evidence);
67
+ updateCodeRefsFromEvidence(root, viewId, evidence, meta);
63
68
  writeState(root, projectViewState(status, Date.now(), readState(root, viewId)));
64
69
 
65
70
  // Build worker args: pi --mode json -p --session <file> [--model m] [--thinking l] [--tools t] <prompt>
@@ -80,6 +85,7 @@ function main() {
80
85
  const worker = spawn(config.piCommand, args, {
81
86
  cwd: config.cwd,
82
87
  stdio: ["ignore", "pipe", "pipe"],
88
+ windowsHide: true,
83
89
  env: process.env,
84
90
  });
85
91
 
@@ -99,6 +105,10 @@ function main() {
99
105
  writeRunEvidence(root, evidence);
100
106
  writeEvidence(root, evidence);
101
107
  writeState(root, projectViewState(status, now, readState(root, viewId)));
108
+ // Best-effort code-refs extraction shells out to git and can take hundreds of
109
+ // ms; run it after the state write so endedAt-visible state converges first.
110
+ // The extraction only depends on evidence + git, never on state.json.
111
+ updateCodeRefsFromEvidence(root, viewId, evidence, meta);
102
112
  dirty = false;
103
113
  };
104
114
 
@@ -212,7 +222,7 @@ function main() {
212
222
  if (applyHeuristicAutoState(config, status, evidence)) {
213
223
  finalizeEvidence(evidence, status, Date.now());
214
224
  status.evidenceSummary = summarizeEvidence(evidence);
215
- persist(true);
225
+ persistUnlessManual(true);
216
226
  }
217
227
  maybeModelAutoState(config, status, evidence)
218
228
  .then((changed) => {
@@ -228,8 +238,18 @@ function main() {
228
238
  })
229
239
  .catch(() => {})
230
240
  .finally(() => {
231
- finalizeSteeringIfNeeded(config, status, evidence);
232
- drainQueuedFollowUp(config, status);
241
+ // The finalize chain must never prevent process.exit: a lock/fs failure
242
+ // here used to pin the runner as a 100% CPU zombie (issue #33).
243
+ try {
244
+ finalizeSteeringIfNeeded(config, status, evidence);
245
+ } catch (err) {
246
+ tryAppendDiagnostic(config, "finalize_steering_failed", err);
247
+ }
248
+ try {
249
+ drainQueuedFollowUp(config, status);
250
+ } catch (err) {
251
+ tryAppendDiagnostic(config, "follow_up_drain_failed", err);
252
+ }
233
253
  process.exit(stoppedByUser ? 0 : (code ?? 0));
234
254
  });
235
255
  });
@@ -239,6 +259,10 @@ function main() {
239
259
  function finalizeSteeringIfNeeded(config, status, evidence) {
240
260
  if (config.kind !== "plan" && config.kind !== "plan_change") return;
241
261
  if (status.semanticState === "failed" || status.semanticState === "stopped") return;
262
+ // A manual completion racing the exit chain must not be resurrected for
263
+ // approval: the user already closed this row. Same signal as the other
264
+ // post-exit guards (completeView writes completed+autoState null to state.json).
265
+ if (isManualCompletion(readState(config.root, config.viewId))) return;
242
266
  recordPlanReady(config.root, config.viewId, {
243
267
  runId: config.runId,
244
268
  planText: latestEvidenceText(evidence) || status.latestAssistantPreview || status.summary || "Plan ready",
@@ -259,6 +283,11 @@ function finalizeSteeringIfNeeded(config, status, evidence) {
259
283
  /** @param {import("../src/core/types.mjs").RunConfig} config @param {import("../src/core/types.mjs").RunStatus} status */
260
284
  function drainQueuedFollowUp(config, status) {
261
285
  if (status.semanticState !== "idle" && status.semanticState !== "completed") return;
286
+ // A manual completion racing the exit chain must never be followed up: the
287
+ // user just finished this row, so don't launch a new run over it. The
288
+ // in-memory status may be stale (fresh-read guards skip classification), so
289
+ // check the authoritative state.json signal.
290
+ if (isManualCompletion(readState(config.root, config.viewId))) return;
262
291
  if (config.kind === "plan" || config.kind === "plan_change") return;
263
292
  const claimed = claimNextFollowUp(config.root, config.viewId);
264
293
  if (!claimed.ok || !claimed.item) return;
@@ -283,6 +312,22 @@ function drainQueuedFollowUp(config, status) {
283
312
  }
284
313
  }
285
314
 
315
+ /** @param {import("../src/core/types.mjs").RunConfig} config @param {string} code @param {unknown} err */
316
+ function tryAppendDiagnostic(config, code, err) {
317
+ try {
318
+ appendDiagnostic(config.root, config.viewId, {
319
+ source: "runner",
320
+ runId: config.runId,
321
+ level: "error",
322
+ code,
323
+ message: "Finalize step failed",
324
+ details: { error: err instanceof Error ? err.message : String(err) },
325
+ });
326
+ } catch {
327
+ /* root may be deleted — nothing to persist, exit anyway */
328
+ }
329
+ }
330
+
286
331
  /** @param {import("../src/core/types.mjs").FollowUpItem} item */
287
332
  function runKindForFollowUp(item) {
288
333
  switch (item.kind) {
@@ -326,6 +371,13 @@ function canAutoState(config, status, evidence) {
326
371
 
327
372
  function applyHeuristicAutoState(config, status, evidence) {
328
373
  if (!canAutoState(config, status, evidence)) return false;
374
+ // Fresh read of state.json (not status.json): completeView writes the manual
375
+ // completion signal (semanticState "completed" + autoState null) to state.json
376
+ // and only clears autoState in status.json, so status.json can never carry
377
+ // the completed+null pair. If the user marked the row done while the worker
378
+ // was exiting, skip classification so the persist path can't clobber it.
379
+ const latestState = readState(config.root, config.viewId);
380
+ if (isManualCompletion(latestState)) return false;
329
381
  const latest = latestEvidenceText(evidence) || status.latestAssistantPreview || status.summary || "";
330
382
  const classification = heuristicAutoState(latest, { lastAgentActivityAt: status.lastAgentActivityAt ?? null });
331
383
  const changed = applyAutoStateToStatus(status, classification, Date.now());
@@ -408,7 +460,7 @@ async function maybeModelSummary(config, status) {
408
460
  function runOneShot(command, args, timeoutMs = 20000) {
409
461
  return new Promise((resolve) => {
410
462
  let out = "";
411
- const child = spawn(command, args, { stdio: ["ignore", "pipe", "ignore"] });
463
+ const child = spawn(command, args, { stdio: ["ignore", "pipe", "ignore"], windowsHide: true });
412
464
  let buf = "";
413
465
  child.stdout.on("data", (c) => {
414
466
  buf += c.toString();
@@ -11,7 +11,11 @@ import { spawn } from "node:child_process";
11
11
  import { createRequire } from "node:module";
12
12
  import { createServer } from "node:net";
13
13
  import { existsSync, unlinkSync } from "node:fs";
14
+ import { tmpdir } from "node:os";
15
+ import { join } from "node:path";
14
16
  import { appendLine, readJson } from "../src/core/atomic.mjs";
17
+ import { appendDiagnostic } from "../src/core/diagnostics.mjs";
18
+ import { finalizeHostCrash } from "../src/core/host-crash.mjs";
15
19
  import * as P from "../src/core/paths.mjs";
16
20
  import { appendBoundedScreenLog, reconcileScreenLog } from "../src/core/screen-log.mjs";
17
21
  import { encodePromptForCliArg } from "../src/core/prompt-transport.mjs";
@@ -54,8 +58,14 @@ function main() {
54
58
  /** @type {Set<import("node:net").Socket>} */
55
59
  const clients = new Set();
56
60
  let childPid = null;
61
+ let child = null;
57
62
  let exitCode = null;
58
63
  let stopping = false;
64
+ // Set when the uncaughtException crash handler finalizes the host. Guards
65
+ // child.onExit against clobbering the persisted "failed" state with an
66
+ // "exited" update (the handler kills the child, so its exit callback fires
67
+ // inside the 50ms flush window — CR round-1, issue #48).
68
+ let crashed = false;
59
69
  /** @type {import("../src/core/types.mjs").HostStatus} */
60
70
  let host = {
61
71
  version: 1,
@@ -75,7 +85,24 @@ function main() {
75
85
  attachedClients: 0,
76
86
  attachedEver: false,
77
87
  };
78
- const persist = () => writeHost(config.root, host);
88
+ /** Persist host.json. A transient failure (e.g. Windows rename EPERM racing a
89
+ * reader) must degrade, not kill the host: record a diagnostic and let the
90
+ * next heartbeat tick retry. Socket protocol is the attach main channel, so
91
+ * host.json being briefly stale is acceptable. */
92
+ const persist = () => {
93
+ try {
94
+ writeHost(config.root, host);
95
+ } catch (err) {
96
+ try {
97
+ appendDiagnostic(config.root, config.viewId, {
98
+ source: "runner",
99
+ level: "error",
100
+ code: "persist_error",
101
+ message: err instanceof Error ? err.message : String(err),
102
+ });
103
+ } catch { /* diagnostics must never kill the host either */ }
104
+ }
105
+ };
79
106
  const broadcast = (msg) => {
80
107
  const line = JSON.stringify(msg) + "\n";
81
108
  for (const c of clients) c.write(line);
@@ -87,6 +114,32 @@ function main() {
87
114
  };
88
115
  persist();
89
116
 
117
+ // Last-resort crash path (registered early, before spawnInteractive, so any
118
+ // early synchronous failure is also covered): the runner is launched detached
119
+ // with stdio ignored, so an uncaught exception is otherwise completely silent —
120
+ // no host.json finalize, no exit message, and the attach view reconnects
121
+ // forever. Record diagnostics, finalize the host as failed, and broadcast exit
122
+ // so attached clients can leave the view instead of looping.
123
+ process.on("uncaughtException", (err) => {
124
+ process.removeAllListeners("uncaughtException");
125
+ try {
126
+ appendDiagnostic(config.root, config.viewId, {
127
+ source: "runner",
128
+ level: "error",
129
+ code: "runner_crash",
130
+ message: err instanceof Error ? err.message : String(err),
131
+ details: { stack: err instanceof Error ? err.stack : undefined },
132
+ });
133
+ } catch { /* best effort */ }
134
+ crashed = true;
135
+ host = finalizeHostCrash(config.root, config.viewId, host, err);
136
+ try {
137
+ broadcast({ type: "exit", exitCode: 1 });
138
+ } catch { /* best effort */ }
139
+ try { if (child) killChild(child, childPid, "SIGTERM"); } catch { /* best effort */ }
140
+ setTimeout(() => process.exit(1), 50).unref?.();
141
+ });
142
+
90
143
  const args = [...config.piArgsPrefix, "--session", config.sessionFile];
91
144
  if (config.model) args.push("--model", config.model);
92
145
  if (config.thinkingLevel) args.push("--thinking", config.thinkingLevel);
@@ -107,7 +160,6 @@ function main() {
107
160
  AGENT_VIEW_HOSTED: "pty",
108
161
  };
109
162
 
110
- let child;
111
163
  try {
112
164
  child = spawnInteractive(config.piCommand, args, {
113
165
  cwd: config.cwd,
@@ -123,7 +175,7 @@ function main() {
123
175
  process.exit(1);
124
176
  }
125
177
  childPid = child.pid ?? null;
126
- update({ childPid, state: "alive" });
178
+ update({ childPid });
127
179
 
128
180
  child.onData((data) => {
129
181
  screenLogBytes = appendBoundedScreenLog(screenLog, data, screenLogBytes, screenLogLimits);
@@ -131,8 +183,12 @@ function main() {
131
183
  });
132
184
  child.onExit((code) => {
133
185
  exitCode = code ?? 0;
134
- update({ state: stopping ? "exited" : "exited", endedAt: Date.now(), exitCode, childPid: null });
135
- broadcast({ type: "exit", exitCode });
186
+ // After a crash the handler already persisted "failed" and broadcast
187
+ // exit; this callback must not overwrite that state.
188
+ if (!crashed) {
189
+ update({ state: stopping ? "exited" : "exited", endedAt: Date.now(), exitCode, childPid: null });
190
+ broadcast({ type: "exit", exitCode });
191
+ }
136
192
  setTimeout(() => process.exit(exitCode ?? 0), 50).unref?.();
137
193
  });
138
194
  child.onError((err) => {
@@ -163,7 +219,7 @@ function main() {
163
219
  });
164
220
  server.on("error", (err) => {
165
221
  update({ state: "failed", endedAt: Date.now(), error: err instanceof Error ? err.message : String(err), exitCode: 1 });
166
- try { child.kill("SIGTERM"); } catch {}
222
+ killChild(child, childPid, "SIGTERM");
167
223
  process.exit(1);
168
224
  });
169
225
  server.listen(socketPath, () => update({ socketPath, state: "alive" }));
@@ -189,11 +245,12 @@ function main() {
189
245
  case "interrupt":
190
246
  child.write("\x1b");
191
247
  break;
192
- case "terminate":
248
+ case "terminate": {
193
249
  stopping = true;
194
- child.kill("SIGTERM");
195
- setTimeout(() => child.kill("SIGKILL"), 4000).unref?.();
250
+ killChild(child, childPid, "SIGTERM");
251
+ setTimeout(() => killChild(child, childPid, "SIGKILL"), 4000).unref?.();
196
252
  break;
253
+ }
197
254
  case "detach":
198
255
  socket.end();
199
256
  break;
@@ -212,7 +269,7 @@ function main() {
212
269
  stopping = true;
213
270
  try { server.close(); } catch {}
214
271
  try { if (existsSync(socketPath)) unlinkSync(socketPath); } catch {}
215
- try { child.kill("SIGTERM"); } catch {}
272
+ killChild(child, childPid, "SIGTERM");
216
273
  setTimeout(() => process.exit(0), 100).unref?.();
217
274
  };
218
275
  process.on("SIGTERM", shutdown);
@@ -245,7 +302,7 @@ function spawnInteractive(command, args, opts) {
245
302
  }
246
303
  if (!opts.allowPipeFallback) throw new Error("node-pty is unavailable");
247
304
 
248
- const proc = spawn(command, args, { cwd: opts.cwd, env: opts.env, stdio: ["pipe", "pipe", "pipe"] });
305
+ const proc = spawn(command, args, { cwd: opts.cwd, env: opts.env, stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
249
306
  return {
250
307
  pid: proc.pid ?? null,
251
308
  write: (s) => proc.stdin.write(s),
@@ -264,6 +321,26 @@ function send(socket, msg) {
264
321
  socket.write(JSON.stringify(msg) + "\n");
265
322
  }
266
323
 
324
+ /**
325
+ * Terminate the hosted child on all platforms. node-pty's kill() throws
326
+ * "Signals not supported on windows", so on win32 we TerminateProcess via
327
+ * process.kill; on unix keep the graceful SIGTERM/SIGKILL path through the pty.
328
+ * @param {{ kill: (signal: string) => void }} child
329
+ * @param {number|null} pid
330
+ * @param {"SIGTERM"|"SIGKILL"} [signal]
331
+ */
332
+ function killChild(child, pid, signal = "SIGTERM") {
333
+ if (process.platform === "win32") {
334
+ if (pid) {
335
+ try {
336
+ process.kill(pid, "SIGKILL");
337
+ return;
338
+ } catch {}
339
+ }
340
+ }
341
+ try { child.kill(signal); } catch {}
342
+ }
343
+
267
344
  function clampInt(value, min, max, fallback) {
268
345
  const n = Number(value);
269
346
  if (!Number.isFinite(n)) return fallback;
@@ -302,7 +379,7 @@ function markRowFailed(root, viewId, message) {
302
379
  }
303
380
 
304
381
  function failEarly(message) {
305
- try { appendLine("/tmp/pi-agent-board-pty-runner.err", message); } catch {}
382
+ try { appendLine(join(tmpdir(), "pi-agent-board-pty-runner.err"), message); } catch {}
306
383
  process.stderr.write(`${message}\n`);
307
384
  process.exit(2);
308
385
  }
@@ -11,7 +11,8 @@ import { readJson } from "../src/core/atomic.mjs";
11
11
  import { appendDiagnostic } from "../src/core/diagnostics.mjs";
12
12
  import { applyAutoStateToStatus, applyAutoStateToViewState, autoStateEnabled, autoStateFromModelOrHeuristic, autoStateModel, buildAutoStatePrompt, heuristicAutoState } from "../src/core/auto-state.mjs";
13
13
  import { finalizeEvidence, readEvidence, summarizeEvidence, writeEvidence } from "../src/core/evidence.mjs";
14
- import { readState, readStatus, writeState, writeStatus } from "../src/core/store.mjs";
14
+ import { updateCodeRefsFromEvidence } from "../src/core/code-refs-store.mjs";
15
+ import { readState, readStatus, readMeta, writeState, writeStatus } from "../src/core/store.mjs";
15
16
 
16
17
  async function main() {
17
18
  const configPath = process.argv[2];
@@ -20,6 +21,9 @@ async function main() {
20
21
  const config = readJson(configPath, null);
21
22
  if (!config || !autoStateEnabled()) process.exit(0);
22
23
 
24
+ // Read the view meta once so code-ref extraction reuses it on the evidence write.
25
+ const meta = readMeta(config.root, config.viewId);
26
+
23
27
  const state = readState(config.root, config.viewId);
24
28
  if (!state || state.processState === "alive" || state.semanticState === "failed" || state.semanticState === "stopped") process.exit(0);
25
29
 
@@ -55,6 +59,7 @@ async function main() {
55
59
  finalizeEvidence(evidence, { semanticState: latestState.semanticState, usage: null }, Date.now());
56
60
  latestState.review = summarizeEvidence(evidence);
57
61
  writeEvidence(config.root, evidence);
62
+ updateCodeRefsFromEvidence(config.root, config.viewId, evidence, meta);
58
63
  writeState(config.root, latestState);
59
64
  if (changed) {
60
65
  appendDiagnostic(config.root, config.viewId, { source: "service", runId: config.runId, code: "auto_state_classified", message: "Auto-state classifier updated row state", details: { kind: classification.kind, confidence: classification.confidence, source: classification.source, reason: classification.reason } });
@@ -86,7 +91,7 @@ function runOneShot(command, args, opts = {}) {
86
91
  return new Promise((resolve) => {
87
92
  let out = "";
88
93
  let settled = false;
89
- const child = spawn(command, args, { cwd: opts.cwd, env: opts.env ?? process.env, stdio: ["ignore", "pipe", "ignore"] });
94
+ const child = spawn(command, args, { cwd: opts.cwd, env: opts.env ?? process.env, stdio: ["ignore", "pipe", "ignore"], windowsHide: true });
90
95
  let buf = "";
91
96
  const finish = () => {
92
97
  if (settled) return;
@@ -45,7 +45,7 @@ async function maybeGenerateTitle(config) {
45
45
  function runOneShot(command, args, timeoutMs = 20000) {
46
46
  return new Promise((resolve) => {
47
47
  let out = "";
48
- const child = spawn(command, args, { stdio: ["ignore", "pipe", "ignore"] });
48
+ const child = spawn(command, args, { stdio: ["ignore", "pipe", "ignore"], windowsHide: true });
49
49
  let buf = "";
50
50
  child.stdout.on("data", (c) => {
51
51
  buf += c.toString();
@@ -16,11 +16,50 @@ import {
16
16
  } from "node:fs";
17
17
  import * as path from "node:path";
18
18
 
19
+ /** Retry backoff (ms) for rename retries; index i is the delay after attempt i. */
20
+ export const RENAME_RETRY_BACKOFF_MS = [10, 50, 250];
21
+ /** Error codes that mean "transient sharing violation" — retryable on Windows. */
22
+ export const RENAME_RETRY_ERROR_CODES = new Set(["EPERM", "EBUSY", "EACCES"]);
23
+
19
24
  /** @param {string} dir */
20
25
  export function ensureDir(dir) {
21
26
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
22
27
  }
23
28
 
29
+ /**
30
+ * rename() with retries for Windows sharing-violation races.
31
+ *
32
+ * On Windows, libuv opens files without FILE_SHARE_DELETE, so replacing an
33
+ * existing target while any other process holds it open for reading throws
34
+ * EPERM/EBUSY/EACCES. The reader window is microseconds, so a few retries
35
+ * with a short backoff succeed almost always. Non-whitelisted errors
36
+ * (e.g. EISDIR) are configuration errors and throw immediately.
37
+ * @param {string} tmp
38
+ * @param {string} file
39
+ * @param {{ rename?: (a: string, b: string) => void, delays?: number[], errorCodes?: Set<string> }} [opts]
40
+ */
41
+ export function renameWithRetry(tmp, file, opts = {}) {
42
+ const rename = opts.rename ?? renameSync;
43
+ const delays = opts.delays ?? RENAME_RETRY_BACKOFF_MS;
44
+ const errorCodes = opts.errorCodes ?? RENAME_RETRY_ERROR_CODES;
45
+ for (let attempt = 0; ; attempt++) {
46
+ try {
47
+ rename(tmp, file);
48
+ return;
49
+ } catch (err) {
50
+ const code = /** @type {NodeJS.ErrnoException} */ (err).code;
51
+ if (!errorCodes.has(code) || attempt >= delays.length) {
52
+ // Exhausted or non-transient: leave no .tmp litter behind, then
53
+ // surface the original error so callers can degrade deliberately.
54
+ try { unlinkSync(tmp); } catch { /* best effort */ }
55
+ throw err;
56
+ }
57
+ }
58
+ // Synchronous sleep (Atomics.wait) — atomicWrite is a sync API.
59
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delays[attempt]);
60
+ }
61
+ }
62
+
24
63
  /**
25
64
  * Atomically write a string to `file` (creates parent dirs).
26
65
  * @param {string} file
@@ -30,7 +69,7 @@ export function atomicWrite(file, data) {
30
69
  ensureDir(path.dirname(file));
31
70
  const tmp = `${file}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
32
71
  writeFileSync(tmp, data, "utf8");
33
- renameSync(tmp, file);
72
+ renameWithRetry(tmp, file);
34
73
  }
35
74
 
36
75
  /**