@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
|
@@ -5,6 +5,24 @@ import { truncate } from "./heuristics.mjs";
|
|
|
5
5
|
import { withViewLockSync } from "./locks.mjs";
|
|
6
6
|
import * as P from "./paths.mjs";
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Run a queue mutation under the view lock, translating any failure (lock
|
|
10
|
+
* unavailable, fs errors inside the mutation) into {ok:false} so callers on
|
|
11
|
+
* the {ok} convention never see a throw (issue #33).
|
|
12
|
+
* @template T
|
|
13
|
+
* @param {string} root
|
|
14
|
+
* @param {string} viewId
|
|
15
|
+
* @param {() => T} fn
|
|
16
|
+
* @returns {T | { ok: false, error: string }}
|
|
17
|
+
*/
|
|
18
|
+
function lockedQueueOp(root, viewId, fn) {
|
|
19
|
+
try {
|
|
20
|
+
return withViewLockSync(root, viewId, "queue", fn);
|
|
21
|
+
} catch (err) {
|
|
22
|
+
return { ok: false, error: `follow-up queue lock unavailable: ${err instanceof Error ? err.message : String(err)}` };
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
8
26
|
/** @param {string} viewId @param {number} [now] @returns {import("./types.mjs").FollowUpQueue} */
|
|
9
27
|
export function emptyFollowUpQueue(viewId, now = Date.now()) {
|
|
10
28
|
return { version: 1, viewId, nextSeq: 1, updatedAt: now, items: [] };
|
|
@@ -41,7 +59,7 @@ export function summarizeFollowUpQueue(queue) {
|
|
|
41
59
|
export function enqueueFollowUp(root, viewId, text, opts = {}) {
|
|
42
60
|
const clean = String(text || "").trim();
|
|
43
61
|
if (!clean) return { ok: false, error: "Empty follow-up" };
|
|
44
|
-
return
|
|
62
|
+
return lockedQueueOp(root, viewId, () => {
|
|
45
63
|
const queue = readFollowUpQueue(root, viewId);
|
|
46
64
|
const now = Date.now();
|
|
47
65
|
const item = {
|
|
@@ -70,7 +88,7 @@ export function enqueueFollowUp(root, viewId, text, opts = {}) {
|
|
|
70
88
|
|
|
71
89
|
/** @param {string} root @param {string} viewId @param {{ runId?: string|null }} [opts] */
|
|
72
90
|
export function claimNextFollowUp(root, viewId, opts = {}) {
|
|
73
|
-
return
|
|
91
|
+
return lockedQueueOp(root, viewId, () => {
|
|
74
92
|
const queue = readFollowUpQueue(root, viewId);
|
|
75
93
|
const item = queue.items.filter((i) => i.status === "queued").sort((a, b) => a.seq - b.seq)[0];
|
|
76
94
|
if (!item) return { ok: false, error: "No queued follow-up" };
|
|
@@ -114,7 +132,7 @@ export function releaseFollowUp(root, viewId, itemId) {
|
|
|
114
132
|
|
|
115
133
|
/** @param {string} root @param {string} viewId */
|
|
116
134
|
export function removeLastFollowUp(root, viewId) {
|
|
117
|
-
return
|
|
135
|
+
return lockedQueueOp(root, viewId, () => {
|
|
118
136
|
const queue = readFollowUpQueue(root, viewId);
|
|
119
137
|
const queued = queue.items.filter((i) => i.status === "queued").sort((a, b) => b.seq - a.seq);
|
|
120
138
|
const last = queued[0];
|
|
@@ -128,7 +146,7 @@ export function removeLastFollowUp(root, viewId) {
|
|
|
128
146
|
|
|
129
147
|
/** @param {string} root @param {string} viewId */
|
|
130
148
|
export function clearQueuedFollowUps(root, viewId) {
|
|
131
|
-
return
|
|
149
|
+
return lockedQueueOp(root, viewId, () => {
|
|
132
150
|
const queue = readFollowUpQueue(root, viewId);
|
|
133
151
|
let cancelled = 0;
|
|
134
152
|
for (const item of queue.items) {
|
|
@@ -145,7 +163,7 @@ export function clearQueuedFollowUps(root, viewId) {
|
|
|
145
163
|
|
|
146
164
|
/** @param {string} root @param {string} viewId @param {string} itemId @param {(item: import("./types.mjs").FollowUpItem) => void} mutate */
|
|
147
165
|
function updateItem(root, viewId, itemId, mutate) {
|
|
148
|
-
return
|
|
166
|
+
return lockedQueueOp(root, viewId, () => {
|
|
149
167
|
const queue = readFollowUpQueue(root, viewId);
|
|
150
168
|
const item = queue.items.find((i) => i.id === itemId);
|
|
151
169
|
if (!item) return { ok: false, error: "Unknown follow-up" };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Crash-time finalization for PTY hosts (used by pty-runner's uncaughtException
|
|
3
|
+
* handler). Pure enough to unit-test: writes a failed host.json + a diagnostic.
|
|
4
|
+
* Never throws — this runs on the crash path.
|
|
5
|
+
*/
|
|
6
|
+
import { appendDiagnostic } from "./diagnostics.mjs";
|
|
7
|
+
import { writeHost } from "./store.mjs";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {string} root
|
|
11
|
+
* @param {string} viewId
|
|
12
|
+
* @param {object|null} host
|
|
13
|
+
* @param {unknown} error
|
|
14
|
+
* @returns {object}
|
|
15
|
+
*/
|
|
16
|
+
export function finalizeHostCrash(root, viewId, host, error) {
|
|
17
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
18
|
+
const failed = {
|
|
19
|
+
...(host ?? { version: 1, viewId, mode: "pty", socketPath: null, startedAt: Date.now() }),
|
|
20
|
+
state: "failed",
|
|
21
|
+
endedAt: Date.now(),
|
|
22
|
+
lastSeenAt: Date.now(),
|
|
23
|
+
exitCode: 1,
|
|
24
|
+
error: message,
|
|
25
|
+
};
|
|
26
|
+
try {
|
|
27
|
+
writeHost(root, failed);
|
|
28
|
+
} catch { /* best effort */ }
|
|
29
|
+
try {
|
|
30
|
+
appendDiagnostic(root, viewId, {
|
|
31
|
+
source: "runner",
|
|
32
|
+
level: "error",
|
|
33
|
+
code: "host_crashed",
|
|
34
|
+
message: `PTY host crashed: ${message}`,
|
|
35
|
+
details: { error: message },
|
|
36
|
+
});
|
|
37
|
+
} catch { /* best effort */ }
|
|
38
|
+
return failed;
|
|
39
|
+
}
|
package/src/core/locks.mjs
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Tiny dependency-free synchronous file lock helpers for local agent-board artifacts.
|
|
3
3
|
* Locks use atomic mkdir on a sibling .lock directory and are cleaned up in finally.
|
|
4
|
+
*
|
|
5
|
+
* Failure model (issue #33): acquisition failures are classified.
|
|
6
|
+
* - EEXIST (contention): wait in 20ms ticks until the stale window passes, then
|
|
7
|
+
* force-steal (bounded to MAX_STEAL_ATTEMPTS). This preserves the original
|
|
8
|
+
* wait/steal contract (see test/locks.test.mjs).
|
|
9
|
+
* - Anything else (deleted parent, read-only fs, permissions, disk full, ...):
|
|
10
|
+
* MAX_ENV_ATTEMPTS quick retries — each retry re-runs ensureDir so a parent
|
|
11
|
+
* deleted mid-acquisition self-heals — then throw LOCK_TIMEOUT. Never spin.
|
|
4
12
|
*/
|
|
5
13
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
6
14
|
import * as path from "node:path";
|
|
@@ -8,22 +16,33 @@ import { ensureDir } from "./atomic.mjs";
|
|
|
8
16
|
import * as P from "./paths.mjs";
|
|
9
17
|
|
|
10
18
|
const DEFAULT_STALE_MS = 30_000;
|
|
19
|
+
/** Minimum contention window before a fresh lock can be force-stolen. */
|
|
20
|
+
const MIN_WINDOW_MS = 250;
|
|
21
|
+
const WAIT_TICK_MS = 20;
|
|
22
|
+
/** Max stale-lock steal attempts before giving up. */
|
|
23
|
+
const MAX_STEAL_ATTEMPTS = 2;
|
|
24
|
+
/** Max quick retries for environmental failures before giving up. */
|
|
25
|
+
const MAX_ENV_ATTEMPTS = 3;
|
|
26
|
+
|
|
27
|
+
export const defaultLocksFs = Object.freeze({ existsSync, mkdirSync, readFileSync, rmSync, writeFileSync });
|
|
11
28
|
|
|
12
29
|
/**
|
|
13
30
|
* @template T
|
|
14
31
|
* @param {string} lockPath
|
|
15
32
|
* @param {() => T} fn
|
|
16
|
-
* @param {{ staleMs?: number }} [opts]
|
|
33
|
+
* @param {{ staleMs?: number, fs?: typeof defaultLocksFs }} [opts]
|
|
17
34
|
* @returns {T}
|
|
18
35
|
*/
|
|
19
36
|
export function withFileLockSync(lockPath, fn, opts = {}) {
|
|
20
|
-
const
|
|
21
|
-
acquireLock(lockPath, staleMs);
|
|
37
|
+
const fs = opts.fs ?? defaultLocksFs;
|
|
38
|
+
acquireLock(lockPath, opts.staleMs ?? DEFAULT_STALE_MS, fs);
|
|
39
|
+
let result;
|
|
22
40
|
try {
|
|
23
|
-
|
|
41
|
+
result = fn();
|
|
24
42
|
} finally {
|
|
25
|
-
releaseLock(lockPath);
|
|
43
|
+
releaseLock(lockPath, fs);
|
|
26
44
|
}
|
|
45
|
+
return result;
|
|
27
46
|
}
|
|
28
47
|
|
|
29
48
|
/**
|
|
@@ -32,37 +51,74 @@ export function withFileLockSync(lockPath, fn, opts = {}) {
|
|
|
32
51
|
* @param {string} viewId
|
|
33
52
|
* @param {string} name
|
|
34
53
|
* @param {() => T} fn
|
|
35
|
-
* @param {{ staleMs?: number }} [opts]
|
|
54
|
+
* @param {{ staleMs?: number, fs?: typeof defaultLocksFs }} [opts]
|
|
36
55
|
* @returns {T}
|
|
37
56
|
*/
|
|
38
57
|
export function withViewLockSync(root, viewId, name, fn, opts = {}) {
|
|
39
58
|
return withFileLockSync(P.viewLockPath(root, viewId, name), fn, opts);
|
|
40
59
|
}
|
|
41
60
|
|
|
42
|
-
/** @param {string} lockPath @param {number} staleMs */
|
|
43
|
-
function acquireLock(lockPath, staleMs) {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
61
|
+
/** @param {string} lockPath @param {number} staleMs @param {typeof defaultLocksFs} fs */
|
|
62
|
+
function acquireLock(lockPath, staleMs, fs) {
|
|
63
|
+
const deadline = Date.now() + Math.max(MIN_WINDOW_MS, staleMs);
|
|
64
|
+
let steals = 0;
|
|
65
|
+
let envAttempts = 0;
|
|
66
|
+
for (;;) {
|
|
67
|
+
let created = false;
|
|
47
68
|
try {
|
|
48
|
-
|
|
49
|
-
|
|
69
|
+
// Re-run every attempt: a parent deleted mid-acquisition self-heals here.
|
|
70
|
+
ensureDir(path.dirname(lockPath));
|
|
71
|
+
fs.mkdirSync(lockPath);
|
|
72
|
+
created = true;
|
|
73
|
+
fs.writeFileSync(
|
|
74
|
+
path.join(lockPath, "owner.json"),
|
|
75
|
+
JSON.stringify({ pid: process.pid, at: Date.now() }),
|
|
76
|
+
"utf8",
|
|
77
|
+
);
|
|
50
78
|
return;
|
|
51
79
|
} catch (err) {
|
|
52
|
-
if (
|
|
53
|
-
|
|
80
|
+
if (err && err.code === "EEXIST") {
|
|
81
|
+
const expired = Date.now() >= deadline;
|
|
82
|
+
if (isLockStale(lockPath, staleMs, fs) || expired) {
|
|
83
|
+
if (steals >= MAX_STEAL_ATTEMPTS) {
|
|
84
|
+
throw lockError(lockPath, `stale lock could not be stolen after ${steals} attempts`);
|
|
85
|
+
}
|
|
86
|
+
steals += 1;
|
|
87
|
+
releaseLock(lockPath, fs);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
sleep(WAIT_TICK_MS);
|
|
54
91
|
continue;
|
|
55
92
|
}
|
|
56
|
-
|
|
93
|
+
// Environmental failure: bounded quick retries, then fail fast.
|
|
94
|
+
if (created) releaseLock(lockPath, fs);
|
|
95
|
+
envAttempts += 1;
|
|
96
|
+
if (envAttempts >= MAX_ENV_ATTEMPTS) {
|
|
97
|
+
const reason = (err && (err.code || err.message)) || "unknown error";
|
|
98
|
+
throw lockError(lockPath, `lock path unusable (${reason})`);
|
|
99
|
+
}
|
|
100
|
+
sleep(WAIT_TICK_MS);
|
|
57
101
|
}
|
|
58
102
|
}
|
|
59
103
|
}
|
|
60
104
|
|
|
61
|
-
/** @param {string} lockPath @param {
|
|
62
|
-
function
|
|
105
|
+
/** @param {string} lockPath @param {string} reason */
|
|
106
|
+
function lockError(lockPath, reason) {
|
|
107
|
+
const err = new Error(`file lock unavailable: ${lockPath} (${reason})`);
|
|
108
|
+
err.code = "LOCK_TIMEOUT";
|
|
109
|
+
return err;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** @param {number} ms */
|
|
113
|
+
function sleep(ms) {
|
|
114
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** @param {string} lockPath @param {number} staleMs @param {typeof defaultLocksFs} fs */
|
|
118
|
+
function isLockStale(lockPath, staleMs, fs) {
|
|
63
119
|
try {
|
|
64
|
-
if (!existsSync(lockPath)) return false;
|
|
65
|
-
const raw = readFileSync(path.join(lockPath, "owner.json"), "utf8");
|
|
120
|
+
if (!fs.existsSync(lockPath)) return false;
|
|
121
|
+
const raw = fs.readFileSync(path.join(lockPath, "owner.json"), "utf8");
|
|
66
122
|
const owner = JSON.parse(raw);
|
|
67
123
|
return Date.now() - Number(owner.at ?? 0) > staleMs;
|
|
68
124
|
} catch {
|
|
@@ -70,10 +126,10 @@ function isLockStale(lockPath, staleMs) {
|
|
|
70
126
|
}
|
|
71
127
|
}
|
|
72
128
|
|
|
73
|
-
/** @param {string} lockPath */
|
|
74
|
-
function releaseLock(lockPath) {
|
|
129
|
+
/** @param {string} lockPath @param {typeof defaultLocksFs} fs */
|
|
130
|
+
function releaseLock(lockPath, fs) {
|
|
75
131
|
try {
|
|
76
|
-
rmSync(lockPath, { recursive: true, force: true });
|
|
132
|
+
fs.rmSync(lockPath, { recursive: true, force: true });
|
|
77
133
|
} catch {
|
|
78
134
|
/* best effort */
|
|
79
135
|
}
|
package/src/core/paths.mjs
CHANGED
|
@@ -23,6 +23,8 @@ export const launchPrefsPath = (root) => path.join(root, "launch-prefs.json");
|
|
|
23
23
|
export const gcHistoryPath = (root) => path.join(root, "gc-history.jsonl");
|
|
24
24
|
/** @param {string} root */
|
|
25
25
|
export const cwdStatsPath = (root) => path.join(root, "cwd-stats.json");
|
|
26
|
+
/** @param {string} root */
|
|
27
|
+
export const providersPath = (root) => path.join(root, "providers.json");
|
|
26
28
|
|
|
27
29
|
/** @param {string} root */
|
|
28
30
|
export const viewsDir = (root) => path.join(root, "views");
|
|
@@ -40,8 +42,27 @@ export const hostConfigPath = (root, viewId) => path.join(viewDir(root, viewId),
|
|
|
40
42
|
export const titleConfigPath = (root, viewId) => path.join(viewDir(root, viewId), "title-config.json");
|
|
41
43
|
/** @param {string} root @param {string} viewId */
|
|
42
44
|
export const autoStateConfigPath = (root, viewId) => path.join(viewDir(root, viewId), "auto-state-config.json");
|
|
45
|
+
/**
|
|
46
|
+
* Named pipe used for the host control socket on Windows. Named pipes are kernel
|
|
47
|
+
* objects, not filesystem entries, so existsSync/unlink never apply to them; they
|
|
48
|
+
* vanish automatically when the owning process exits. Names must be ≤ 256 chars.
|
|
49
|
+
* @param {string} viewId
|
|
50
|
+
*/
|
|
51
|
+
export const controlPipeName = (viewId) => `\\\\.\\pipe\\pi-agent-board-${viewId}`;
|
|
52
|
+
/**
|
|
53
|
+
* Control socket address for a view, platform-aware. On Windows plain filesystem
|
|
54
|
+
* paths cannot be bound by node's net module (EACCES) — only `\\.\pipe\`-style
|
|
55
|
+
* names work (see libuv#458, nodejs/node#55979).
|
|
56
|
+
* @param {"win32"|"linux"|"darwin"} platform
|
|
57
|
+
* @param {string} root
|
|
58
|
+
* @param {string} viewId
|
|
59
|
+
*/
|
|
60
|
+
export function controlSocketPathFor(platform, root, viewId) {
|
|
61
|
+
if (platform === "win32") return controlPipeName(viewId);
|
|
62
|
+
return path.join(viewDir(root, viewId), "control.sock");
|
|
63
|
+
}
|
|
43
64
|
/** @param {string} root @param {string} viewId */
|
|
44
|
-
export const controlSocketPath = (root, viewId) =>
|
|
65
|
+
export const controlSocketPath = (root, viewId) => controlSocketPathFor(process.platform, root, viewId);
|
|
45
66
|
/** @param {string} root @param {string} viewId */
|
|
46
67
|
export const screenLogPath = (root, viewId) => path.join(viewDir(root, viewId), "screen.log");
|
|
47
68
|
/** @param {string} root @param {string} viewId */
|
|
@@ -65,6 +86,8 @@ export const diagnosticsPath = (root, viewId) => path.join(viewDir(root, viewId)
|
|
|
65
86
|
/** @param {string} root @param {string} viewId */
|
|
66
87
|
export const evidencePath = (root, viewId) => path.join(viewDir(root, viewId), "evidence.json");
|
|
67
88
|
/** @param {string} root @param {string} viewId */
|
|
89
|
+
export const codeRefsPath = (root, viewId) => path.join(viewDir(root, viewId), "github.json");
|
|
90
|
+
/** @param {string} root @param {string} viewId */
|
|
68
91
|
export const viewEvidencePath = evidencePath;
|
|
69
92
|
/** @param {string} root @param {string} viewId */
|
|
70
93
|
export const followUpQueuePath = (root, viewId) => path.join(viewDir(root, viewId), "queue.json");
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Attach reconnect policy (issue #48). The pty-runner can die without sending
|
|
3
|
+
* an exit message (uncaught crash), which previously left the attach view in a
|
|
4
|
+
* 150 ms reconnect loop forever with no way out. Two regimes:
|
|
5
|
+
* - ever connected: a crashed host is unrecoverable quickly — give up after a
|
|
6
|
+
* short window and report "host exited";
|
|
7
|
+
* - never connected: the host may still be cold-starting (service launches it
|
|
8
|
+
* right before attach) — allow a long window before declaring it unreachable.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Give-up delay after a previously-established connection drops (ms). */
|
|
12
|
+
export const ATTACH_RECONNECT_TIMEOUT_MS = 15_000;
|
|
13
|
+
/** Give-up delay while waiting for the initial host connection (ms). */
|
|
14
|
+
export const ATTACH_HOST_START_TIMEOUT_MS = 120_000;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {{ everConnected: boolean, disconnectedAt: number|null, connectStartedAt: number, now: number }} input
|
|
18
|
+
* @returns {{ giveUp: boolean, status: string | null }}
|
|
19
|
+
*/
|
|
20
|
+
export function evaluateAttachReconnect({ everConnected, disconnectedAt, connectStartedAt, now }) {
|
|
21
|
+
if (everConnected && disconnectedAt !== null) {
|
|
22
|
+
const elapsed = now - disconnectedAt;
|
|
23
|
+
return elapsed >= ATTACH_RECONNECT_TIMEOUT_MS
|
|
24
|
+
? { giveUp: true, status: "host exited" }
|
|
25
|
+
: { giveUp: false, status: null };
|
|
26
|
+
}
|
|
27
|
+
const elapsed = now - connectStartedAt;
|
|
28
|
+
return elapsed >= ATTACH_HOST_START_TIMEOUT_MS
|
|
29
|
+
? { giveUp: true, status: "host not reachable" }
|
|
30
|
+
: { giveUp: false, status: null };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Detach-key policy: while the socket is down a key can never reach the child,
|
|
35
|
+
* so treat ← / ctrl+] as "leave the view" unconditionally; otherwise keep the
|
|
36
|
+
* existing empty-input-line gate (issue #48 comment 1).
|
|
37
|
+
* @param {boolean} connected
|
|
38
|
+
* @param {boolean} childInputLooksEmpty
|
|
39
|
+
* @returns {boolean}
|
|
40
|
+
*/
|
|
41
|
+
export function shouldEscapeAttach(connected, childInputLooksEmpty) {
|
|
42
|
+
return !connected || childInputLooksEmpty;
|
|
43
|
+
}
|
package/src/core/repo.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** Git repository identity. Pure node (shells out to `git`); returns null off-repo. */
|
|
2
2
|
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { parseRemoteHost } from "./code-refs.mjs";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Resolve the git repo root containing `cwd`, or null if not in a repo.
|
|
@@ -45,3 +46,55 @@ export function isDirty(repoRoot) {
|
|
|
45
46
|
return false;
|
|
46
47
|
}
|
|
47
48
|
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Cached origin-remote URL per repo root. Failures are cached as null so each
|
|
52
|
+
* repo root is only ever queried once per process.
|
|
53
|
+
* @type {Map<string, string|null>}
|
|
54
|
+
*/
|
|
55
|
+
const remoteUrlCache = new Map();
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Raw URL of the `origin` remote of `repoRoot`, or null when the repo has no
|
|
59
|
+
* origin remote or is not a repo. The result (including misses) is cached per
|
|
60
|
+
* repoRoot.
|
|
61
|
+
* @param {string} repoRoot
|
|
62
|
+
* @returns {string|null}
|
|
63
|
+
*/
|
|
64
|
+
export function gitRemoteUrl(repoRoot) {
|
|
65
|
+
const cached = remoteUrlCache.get(repoRoot);
|
|
66
|
+
if (cached !== undefined) return cached;
|
|
67
|
+
let url = null;
|
|
68
|
+
try {
|
|
69
|
+
const out = execFileSync("git", ["-C", repoRoot, "remote", "get-url", "origin"], {
|
|
70
|
+
encoding: "utf8",
|
|
71
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
72
|
+
timeout: 2000,
|
|
73
|
+
});
|
|
74
|
+
url = out.trim() || null;
|
|
75
|
+
} catch {
|
|
76
|
+
// not a repo or no origin remote
|
|
77
|
+
}
|
|
78
|
+
remoteUrlCache.set(repoRoot, url);
|
|
79
|
+
return url;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Host of the `origin` remote of `repoRoot`, lowercased and without port, or
|
|
84
|
+
* null when the repo has no origin remote or is not a repo. Supports
|
|
85
|
+
* `https://host/owner/repo(.git)` and `git@host:owner/repo(.git)`; parsed from
|
|
86
|
+
* the cached raw remote URL.
|
|
87
|
+
* @param {string} repoRoot
|
|
88
|
+
* @returns {string|null}
|
|
89
|
+
*/
|
|
90
|
+
export function gitRemoteHost(repoRoot) {
|
|
91
|
+
return parseRemoteHost(gitRemoteUrl(repoRoot));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Clear the gitRemoteHost cache. Test-only helper.
|
|
96
|
+
* @returns {void}
|
|
97
|
+
*/
|
|
98
|
+
export function clearRemoteHostCacheForTests() {
|
|
99
|
+
remoteUrlCache.clear();
|
|
100
|
+
}
|
package/src/core/rows.mjs
CHANGED
|
@@ -10,6 +10,13 @@ import { GROUP_LABELS, GROUP_ORDER, SEMANTIC_STATES } from "./types.mjs";
|
|
|
10
10
|
/** @typedef {import("./store.mjs").Row} Row */
|
|
11
11
|
/** @typedef {import("./types.mjs").SemanticState} SemanticState */
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Host heartbeat staleness threshold (ms). The pty-runner writes host.json on a
|
|
15
|
+
* 1 Hz heartbeat; when a crashed runner leaves host.json stuck on "alive", the
|
|
16
|
+
* heartbeat goes stale within seconds (issue #48 L4).
|
|
17
|
+
*/
|
|
18
|
+
const STALE_HOST_MS = 10_000;
|
|
19
|
+
|
|
13
20
|
/**
|
|
14
21
|
* @typedef {Object} RowView
|
|
15
22
|
* @property {string} id
|
|
@@ -24,6 +31,8 @@ import { GROUP_LABELS, GROUP_ORDER, SEMANTIC_STATES } from "./types.mjs";
|
|
|
24
31
|
* @property {SemanticState} state
|
|
25
32
|
* @property {boolean} alive
|
|
26
33
|
* @property {boolean} hostAlive
|
|
34
|
+
* @property {boolean} staleHost True when host.json claims alive but the runner
|
|
35
|
+
* process is gone and the heartbeat is stale (display-only crash marker).
|
|
27
36
|
* @property {boolean} needsInput
|
|
28
37
|
* @property {boolean} hasError
|
|
29
38
|
* @property {boolean} worktree
|
|
@@ -35,6 +44,9 @@ import { GROUP_LABELS, GROUP_ORDER, SEMANTIC_STATES } from "./types.mjs";
|
|
|
35
44
|
* @property {number} followUpCount
|
|
36
45
|
* @property {string|null} followUpPreview
|
|
37
46
|
* @property {string} steeringState
|
|
47
|
+
* @property {string} refsBadge Inline issue/PR badge (e.g. "#40 ▸#45"); "" when there are no refs.
|
|
48
|
+
* @property {boolean} refsLowConfidence True when the winning issue or pr confidence is "low".
|
|
49
|
+
* @property {import("./types.mjs").CodeRefsSummary|null} codeRefs Compact ref summary the peek view consumes.
|
|
38
50
|
* @property {number} lastActivityAt
|
|
39
51
|
* @property {number} createdAt
|
|
40
52
|
*/
|
|
@@ -115,6 +127,19 @@ export function rowView(row, now) {
|
|
|
115
127
|
const place = folderName;
|
|
116
128
|
const lastVisitedAt = row.state?.lastVisitedAt ?? null;
|
|
117
129
|
const lastAgentActivityAt = row.state?.lastAgentActivityAt ?? null;
|
|
130
|
+
const refs = codeRefsBadge(row.codeRefs);
|
|
131
|
+
// Host-lost display marker (issue #48 L4): host.json still claims "alive"
|
|
132
|
+
// but the runner process is gone and the 1 Hz heartbeat went stale — the
|
|
133
|
+
// crash path leaves exactly this fingerprint (no exit message, no
|
|
134
|
+
// reconcile). Display-only; state.json is deliberately untouched.
|
|
135
|
+
const host = row.host;
|
|
136
|
+
const staleHost = Boolean(
|
|
137
|
+
host &&
|
|
138
|
+
host.state === "alive" &&
|
|
139
|
+
!row.hostAlive &&
|
|
140
|
+
typeof host.lastSeenAt === "number" &&
|
|
141
|
+
now - host.lastSeenAt > STALE_HOST_MS,
|
|
142
|
+
);
|
|
118
143
|
return {
|
|
119
144
|
id: row.meta.id,
|
|
120
145
|
name: row.meta.name,
|
|
@@ -128,6 +153,7 @@ export function rowView(row, now) {
|
|
|
128
153
|
state,
|
|
129
154
|
alive: Boolean(row.alive),
|
|
130
155
|
hostAlive: Boolean(row.hostAlive),
|
|
156
|
+
staleHost,
|
|
131
157
|
needsInput: state === "needs_input",
|
|
132
158
|
hasError: state === "failed",
|
|
133
159
|
worktree,
|
|
@@ -139,11 +165,35 @@ export function rowView(row, now) {
|
|
|
139
165
|
followUpCount: row.state?.followUps?.queuedCount ?? 0,
|
|
140
166
|
followUpPreview: row.state?.followUps?.lastQueuedPreview ?? null,
|
|
141
167
|
steeringState: row.state?.steering?.status ?? "none",
|
|
168
|
+
refsBadge: refs.refsBadge,
|
|
169
|
+
refsLowConfidence: refs.refsLowConfidence,
|
|
170
|
+
codeRefs: row.codeRefs ?? null,
|
|
142
171
|
lastActivityAt,
|
|
143
172
|
createdAt: row.meta.createdAt ?? 0,
|
|
144
173
|
};
|
|
145
174
|
}
|
|
146
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Map a CodeRefsSummary to the inline badge strings shown on dashboard rows:
|
|
178
|
+
* the winning issue and pr joined by a space, each prefixed with the provider
|
|
179
|
+
* prefix stored on the summary; plus the low-confidence flag that makes the
|
|
180
|
+
* dashboard dim the badge. Missing/garbage summaries yield an empty badge.
|
|
181
|
+
* @param {import("./types.mjs").CodeRefsSummary|null|undefined} summary
|
|
182
|
+
* @returns {{ refsBadge: string, refsLowConfidence: boolean }}
|
|
183
|
+
*/
|
|
184
|
+
function codeRefsBadge(summary) {
|
|
185
|
+
if (!summary) return { refsBadge: "", refsLowConfidence: false };
|
|
186
|
+
const issue = summary.issue;
|
|
187
|
+
const pr = summary.pr;
|
|
188
|
+
const parts = [];
|
|
189
|
+
if (issue && Number.isFinite(issue.number)) parts.push(`${summary.issuePrefix ?? "#"}${issue.number}`);
|
|
190
|
+
if (pr && Number.isFinite(pr.number)) parts.push(`${summary.prPrefix ?? "▸#"}${pr.number}`);
|
|
191
|
+
return {
|
|
192
|
+
refsBadge: parts.join(" "),
|
|
193
|
+
refsLowConfidence: issue?.confidence === "low" || pr?.confidence === "low",
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
147
197
|
/** @param {string} text */
|
|
148
198
|
function oneLine(text) {
|
|
149
199
|
return String(text || "").replace(/\s+/g, " ").trim() || "—";
|
package/src/core/store.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import { existsSync, readdirSync, statSync } from "node:fs";
|
|
|
7
7
|
import { atomicWriteJson, ensureDir, readJson } from "./atomic.mjs";
|
|
8
8
|
import * as P from "./paths.mjs";
|
|
9
9
|
import { isAlive } from "./pid.mjs";
|
|
10
|
+
import { readCodeRefs, summarizeCodeRefs } from "./code-refs-store.mjs";
|
|
10
11
|
import { readDiagnosticSummary } from "./diagnostics.mjs";
|
|
11
12
|
import { readEvidence, summarizeEvidence } from "./evidence.mjs";
|
|
12
13
|
import { readFollowUpQueue, summarizeFollowUpQueue } from "./follow-up-queue.mjs";
|
|
@@ -181,6 +182,7 @@ function mtime(dir) {
|
|
|
181
182
|
* @property {import("./types.mjs").DiagnosticSummary} [diagnostics]
|
|
182
183
|
* @property {import("./types.mjs").FollowUpSummary} [followUps]
|
|
183
184
|
* @property {import("./types.mjs").SteeringSummary} [steering]
|
|
185
|
+
* @property {import("./types.mjs").CodeRefsSummary} [codeRefs]
|
|
184
186
|
*/
|
|
185
187
|
|
|
186
188
|
/** @param {string} root @param {string} viewId @returns {Row|null} */
|
|
@@ -211,7 +213,8 @@ export function readViewArtifactSummaries(root, viewId) {
|
|
|
211
213
|
const diagnostics = readDiagnosticSummary(root, viewId);
|
|
212
214
|
const followUps = summarizeFollowUpQueue(readFollowUpQueue(root, viewId));
|
|
213
215
|
const steering = summarizeSteering(readSteering(root, viewId));
|
|
214
|
-
|
|
216
|
+
const codeRefs = summarizeCodeRefs(readCodeRefs(root, viewId));
|
|
217
|
+
return { review, diagnostics, followUps, steering, codeRefs };
|
|
215
218
|
}
|
|
216
219
|
|
|
217
220
|
/**
|
package/src/core/types.mjs
CHANGED
|
@@ -118,6 +118,7 @@ export const GROUP_LABELS = {
|
|
|
118
118
|
* @property {DiagnosticSummary} [diagnostics] Compact diagnostics summary.
|
|
119
119
|
* @property {FollowUpSummary} [followUps] Compact queued follow-up summary.
|
|
120
120
|
* @property {SteeringSummary} [steering] Compact plan/approval steering summary.
|
|
121
|
+
* @property {CodeRefsSummary} [codeRefs] Compact issue/PR reference summary.
|
|
121
122
|
* @property {AutoStateClassification|null} [autoState] Latest automatic terminal-state classification.
|
|
122
123
|
*/
|
|
123
124
|
|
|
@@ -364,6 +365,17 @@ export const GROUP_LABELS = {
|
|
|
364
365
|
|
|
365
366
|
/** @typedef {{ status:SteeringModeState, awaitingApproval:boolean, planPreview:string|null, updatedAt:number|null, question:string|null }} SteeringSummary */
|
|
366
367
|
|
|
368
|
+
/**
|
|
369
|
+
* Compact issue/PR reference summary for a view (`github.json`).
|
|
370
|
+
* @typedef {Object} CodeRefsSummary
|
|
371
|
+
* @property {string|null} provider Resolved provider name (e.g. "github"); always non-null after a successful extraction (falls back to "generic"), null only before any extraction has run.
|
|
372
|
+
* @property {string} issuePrefix Issue-number prefix resolved from the matched provider at write time (default "#").
|
|
373
|
+
* @property {string} prPrefix PR/MR-number prefix resolved from the matched provider at write time (default "▸#"; "!" for gitlab).
|
|
374
|
+
* @property {import("./code-refs.mjs").Ref|null} issue
|
|
375
|
+
* @property {import("./code-refs.mjs").Ref|null} pr
|
|
376
|
+
* @property {import("./code-refs.mjs").Ref[]} allRefs Distinct refs for the peek view (max 10).
|
|
377
|
+
*/
|
|
378
|
+
|
|
367
379
|
/** Roster index (`roster.json`). @typedef {Object} Roster @property {number} version @property {string[]} views */
|
|
368
380
|
|
|
369
381
|
/**
|
package/src/runtime/service.mjs
CHANGED
|
@@ -13,6 +13,7 @@ import { appendLine } from "../core/atomic.mjs";
|
|
|
13
13
|
import { finalizeRun, projectViewState, reduceEvent } from "../core/events.mjs";
|
|
14
14
|
import { clearDiagnostics, appendDiagnostic, tailDiagnostics } from "../core/diagnostics.mjs";
|
|
15
15
|
import { emptyEvidenceSnapshot, finalizeEvidence, readEvidence, reduceEvidence, summarizeEvidence, writeEvidence } from "../core/evidence.mjs";
|
|
16
|
+
import { updateCodeRefsFromEvidence } from "../core/code-refs-store.mjs";
|
|
16
17
|
import { claimNextFollowUp, completeFollowUp, enqueueFollowUp, readFollowUpQueue, releaseFollowUp, summarizeFollowUpQueue, clearQueuedFollowUps, removeLastFollowUp } from "../core/follow-up-queue.mjs";
|
|
17
18
|
import { approvePlan as approvePlanState, markExecutingApprovedPlan, readSteering, recordPlanReady, requestPlan as requestPlanState, requestPlanChanges as requestPlanChangesState, summarizeSteering } from "../core/steering.mjs";
|
|
18
19
|
import { buildApprovePlanPrompt, buildPlanChangesPrompt, buildPlanRequestPrompt } from "../core/steering-prompts.mjs";
|
|
@@ -517,6 +518,7 @@ export function createService(opts) {
|
|
|
517
518
|
try {
|
|
518
519
|
reduceEvidence(evidence, event, now);
|
|
519
520
|
writeEvidence(root, evidence);
|
|
521
|
+
updateCodeRefsFromEvidence(root, row.meta.id, evidence, row.meta);
|
|
520
522
|
} catch (err) {
|
|
521
523
|
appendDiagnostic(root, row.meta.id, { source: "evidence", level: "warn", code: "evidence_reduce_failed", message: "Could not reduce hosted evidence", details: { error: err instanceof Error ? err.message : String(err) } });
|
|
522
524
|
}
|
|
@@ -548,6 +550,7 @@ export function createService(opts) {
|
|
|
548
550
|
}
|
|
549
551
|
status.evidenceSummary = summarizeEvidence(evidence);
|
|
550
552
|
writeEvidence(root, evidence);
|
|
553
|
+
updateCodeRefsFromEvidence(root, row.meta.id, evidence, row.meta);
|
|
551
554
|
writeForegroundState(row, status);
|
|
552
555
|
pruneWarmHosts({ keepViewId: row.meta.id });
|
|
553
556
|
drainNextFollowUp(row.meta.id);
|
|
@@ -1142,7 +1145,10 @@ function compactSummary(text) {
|
|
|
1142
1145
|
function sendHostMessage(row, message) {
|
|
1143
1146
|
const socketPath = row.host?.socketPath;
|
|
1144
1147
|
if (!socketPath) return { ok: false, error: "No host socket" };
|
|
1145
|
-
|
|
1148
|
+
// On Windows the control socket is a named pipe, which never exists as a
|
|
1149
|
+
// filesystem entry, so existsSync can't be used as a readiness probe there.
|
|
1150
|
+
// A missing pipe surfaces as a connection error on the socket below.
|
|
1151
|
+
if (process.platform !== "win32" && !existsSync(socketPath)) return { ok: false, error: "Host socket is not ready" };
|
|
1146
1152
|
try {
|
|
1147
1153
|
const socket = createConnection(socketPath);
|
|
1148
1154
|
socket.on("connect", () => {
|
package/src/ui/dashboard.ts
CHANGED
|
@@ -1362,8 +1362,9 @@ export class DashboardComponent implements Component {
|
|
|
1362
1362
|
const contentWidth = Math.max(20, width - indent);
|
|
1363
1363
|
const glyph = stateGlyph(rv.state, rv.alive, rv.hostAlive, rv.unread);
|
|
1364
1364
|
const marker = stageFg(rv.state, selected ? `›${glyph}` : ` ${glyph}`);
|
|
1365
|
-
const statusBadges = `${rv.reviewReady ? "✓ " : ""}${rv.diagnosticStalled ? "⏳ " : ""}${rv.evidenceErrorCount || rv.diagnosticErrorCount ? "! " : ""}${rv.followUpCount ? `q${rv.followUpCount} ` : ""}${rv.steeringState && rv.steeringState !== "none" ? "π " : ""}`;
|
|
1366
|
-
const
|
|
1365
|
+
const statusBadges = `${rv.reviewReady ? "✓ " : ""}${rv.diagnosticStalled ? "⏳ " : ""}${rv.staleHost ? "⌁ " : ""}${rv.evidenceErrorCount || rv.diagnosticErrorCount ? "! " : ""}${rv.followUpCount ? `q${rv.followUpCount} ` : ""}${rv.steeringState && rv.steeringState !== "none" ? "π " : ""}`;
|
|
1366
|
+
const refsBadge = rv.refsBadge ? (rv.refsLowConfidence ? t.fg("dim", rv.refsBadge) : rv.refsBadge) : "";
|
|
1367
|
+
const badge = `${this.mode === "select" ? `${this.isSelectedForBatch(rv.id) ? "◉" : "○"} ` : ""}${rv.pinned ? "★ " : ""}${rv.worktree ? "⌥ " : ""}${statusBadges}${refsBadge ? `${refsBadge} ` : ""}`;
|
|
1367
1368
|
const ageRaw = ` ${rv.age}`;
|
|
1368
1369
|
const nameW = clamp(Math.floor(contentWidth * 0.34), 18, 30);
|
|
1369
1370
|
const folderW = opts.showFolder !== false ? (contentWidth >= 72 ? clamp(Math.floor(contentWidth * 0.16), 10, 22) : contentWidth >= 56 ? 10 : 0) : 0;
|
|
@@ -1396,6 +1397,17 @@ export class DashboardComponent implements Component {
|
|
|
1396
1397
|
out.push(t.fg("muted", "Auto-state"));
|
|
1397
1398
|
out.push(clip(` ${auto.kind} · ${auto.confidence} · ${auto.source}${auto.reason ? ` — ${auto.reason}` : ""}`, width));
|
|
1398
1399
|
}
|
|
1400
|
+
if (row.codeRefs && row.codeRefs.allRefs.length > 0) {
|
|
1401
|
+
const codeRefs = row.codeRefs;
|
|
1402
|
+
out.push("");
|
|
1403
|
+
out.push(t.fg("muted", "Refs"));
|
|
1404
|
+
if (codeRefs.provider) out.push(clip(` ${codeRefs.provider}`, width));
|
|
1405
|
+
for (const ref of codeRefs.allRefs) {
|
|
1406
|
+
const prefix = ref.kind === "pr" ? (codeRefs.prPrefix ?? "▸#") : (codeRefs.issuePrefix ?? "#");
|
|
1407
|
+
const line = ` ${ref.kind} ${prefix}${ref.number} · ${ref.confidence} · ${ref.source}${ref.url ? ` · ${ref.url}` : ""}`;
|
|
1408
|
+
out.push(clip(line, width));
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1399
1411
|
if (row.state?.question) {
|
|
1400
1412
|
out.push("");
|
|
1401
1413
|
out.push(t.fg("warning", "Question / blocker"));
|