@zhuxixi/pi-agent-board 0.4.3 → 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 +1 -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-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 +1 -1
- package/runner/job-runner.mjs +29 -3
- 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/host-crash.mjs +39 -0
- 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
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) => {
|
|
@@ -249,6 +259,10 @@ function main() {
|
|
|
249
259
|
function finalizeSteeringIfNeeded(config, status, evidence) {
|
|
250
260
|
if (config.kind !== "plan" && config.kind !== "plan_change") return;
|
|
251
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;
|
|
252
266
|
recordPlanReady(config.root, config.viewId, {
|
|
253
267
|
runId: config.runId,
|
|
254
268
|
planText: latestEvidenceText(evidence) || status.latestAssistantPreview || status.summary || "Plan ready",
|
|
@@ -269,6 +283,11 @@ function finalizeSteeringIfNeeded(config, status, evidence) {
|
|
|
269
283
|
/** @param {import("../src/core/types.mjs").RunConfig} config @param {import("../src/core/types.mjs").RunStatus} status */
|
|
270
284
|
function drainQueuedFollowUp(config, status) {
|
|
271
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;
|
|
272
291
|
if (config.kind === "plan" || config.kind === "plan_change") return;
|
|
273
292
|
const claimed = claimNextFollowUp(config.root, config.viewId);
|
|
274
293
|
if (!claimed.ok || !claimed.item) return;
|
|
@@ -352,6 +371,13 @@ function canAutoState(config, status, evidence) {
|
|
|
352
371
|
|
|
353
372
|
function applyHeuristicAutoState(config, status, evidence) {
|
|
354
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;
|
|
355
381
|
const latest = latestEvidenceText(evidence) || status.latestAssistantPreview || status.summary || "";
|
|
356
382
|
const classification = heuristicAutoState(latest, { lastAgentActivityAt: status.lastAgentActivityAt ?? null });
|
|
357
383
|
const changed = applyAutoStateToStatus(status, classification, Date.now());
|
|
@@ -434,7 +460,7 @@ async function maybeModelSummary(config, status) {
|
|
|
434
460
|
function runOneShot(command, args, timeoutMs = 20000) {
|
|
435
461
|
return new Promise((resolve) => {
|
|
436
462
|
let out = "";
|
|
437
|
-
const child = spawn(command, args, { stdio: ["ignore", "pipe", "ignore"] });
|
|
463
|
+
const child = spawn(command, args, { stdio: ["ignore", "pipe", "ignore"], windowsHide: true });
|
|
438
464
|
let buf = "";
|
|
439
465
|
child.stdout.on("data", (c) => {
|
|
440
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
|
/**
|