@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.
- package/README.md +16 -1
- package/docs/superpowers/plans/2026-08-27-locks-acquirelock-spin.md +614 -0
- package/docs/superpowers/plans/2026-08-27-pty-runner-test-flaky.md +32 -0
- package/docs/superpowers/plans/2026-08-29-code-refs-badges.md +223 -0
- package/docs/superpowers/plans/2026-08-30-post-exit-timing-fix.md +37 -0
- package/docs/superpowers/specs/2026-08-27-locks-acquirelock-spin-design.md +97 -0
- package/docs/superpowers/specs/2026-08-27-pty-runner-test-flaky-design.md +35 -0
- package/docs/superpowers/specs/2026-08-29-code-refs-badges-design.md +128 -0
- package/docs/superpowers/specs/2026-08-30-eprm-atomicwrite-race-design.md +77 -0
- package/docs/superpowers/specs/2026-08-30-post-exit-timing-fix-design.md +80 -0
- package/package.json +4 -2
- package/runner/job-runner.mjs +57 -5
- package/runner/pty-runner.mjs +89 -12
- package/runner/state-runner.mjs +7 -2
- package/runner/title-runner.mjs +1 -1
- package/src/core/atomic.mjs +40 -1
- package/src/core/code-refs-store.mjs +312 -0
- package/src/core/code-refs.mjs +861 -0
- package/src/core/follow-up-queue.mjs +23 -5
- package/src/core/host-crash.mjs +39 -0
- package/src/core/locks.mjs +79 -23
- package/src/core/paths.mjs +24 -1
- package/src/core/pty-attach-reconnect.mjs +43 -0
- package/src/core/repo.mjs +53 -0
- package/src/core/rows.mjs +50 -0
- package/src/core/store.mjs +4 -1
- package/src/core/types.mjs +12 -0
- package/src/runtime/service.mjs +7 -1
- package/src/ui/dashboard.ts +14 -2
- package/src/ui/pty-attach.ts +32 -3
|
@@ -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.
|
|
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": {
|
package/runner/job-runner.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
232
|
-
|
|
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();
|
package/runner/pty-runner.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
135
|
-
|
|
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
|
-
|
|
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
|
|
195
|
-
setTimeout(() => child
|
|
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
|
-
|
|
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("
|
|
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
|
}
|
package/runner/state-runner.mjs
CHANGED
|
@@ -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 {
|
|
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;
|
package/runner/title-runner.mjs
CHANGED
|
@@ -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();
|
package/src/core/atomic.mjs
CHANGED
|
@@ -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
|
-
|
|
72
|
+
renameWithRetry(tmp, file);
|
|
34
73
|
}
|
|
35
74
|
|
|
36
75
|
/**
|