@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,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-view code-refs artifact (`github.json`) plus the evidence→extraction
|
|
3
|
+
* hook helper.
|
|
4
|
+
*
|
|
5
|
+
* Mirrors evidence.mjs's normalize/read/write/summarize shape. Extraction is
|
|
6
|
+
* delegated to the pure engine in code-refs.mjs; this module only composes the
|
|
7
|
+
* engine input from a view's meta + evidence and persists the snapshot
|
|
8
|
+
* atomically. `meta` is passed in by callers (they already hold it) so this
|
|
9
|
+
* module never imports store.mjs — keeping the store ↔ artifact imports free
|
|
10
|
+
* of cycles.
|
|
11
|
+
*/
|
|
12
|
+
import { execFileSync } from "node:child_process";
|
|
13
|
+
import { statSync } from "node:fs";
|
|
14
|
+
import { atomicWriteJson, readJson } from "./atomic.mjs";
|
|
15
|
+
import * as P from "./paths.mjs";
|
|
16
|
+
import {
|
|
17
|
+
extractCodeRefs,
|
|
18
|
+
loadProvidersWithErrors,
|
|
19
|
+
matchProvider,
|
|
20
|
+
parseRemoteHost,
|
|
21
|
+
parseRemotePath,
|
|
22
|
+
} from "./code-refs.mjs";
|
|
23
|
+
import { gitRemoteUrl } from "./repo.mjs";
|
|
24
|
+
import { appendDiagnostic } from "./diagnostics.mjs";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @typedef {Object} CodeRefsSnapshot
|
|
28
|
+
* @property {number} version
|
|
29
|
+
* @property {string} viewId
|
|
30
|
+
* @property {number} updatedAt
|
|
31
|
+
* @property {string|null} provider
|
|
32
|
+
* @property {string} issuePrefix Issue-number prefix resolved from the matched provider (default "#").
|
|
33
|
+
* @property {string} prPrefix PR/MR-number prefix resolved from the matched provider (default "▸#").
|
|
34
|
+
* @property {import("./code-refs.mjs").Ref|null} issue
|
|
35
|
+
* @property {import("./code-refs.mjs").Ref|null} pr
|
|
36
|
+
* @property {import("./code-refs.mjs").Ref[]} allRefs
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/** @param {{ viewId:string, now?:number }} opts @returns {CodeRefsSnapshot} */
|
|
40
|
+
export function emptyCodeRefsSnapshot(opts) {
|
|
41
|
+
const now = opts.now ?? Date.now();
|
|
42
|
+
return {
|
|
43
|
+
version: 1,
|
|
44
|
+
viewId: opts.viewId,
|
|
45
|
+
updatedAt: now,
|
|
46
|
+
provider: null,
|
|
47
|
+
issuePrefix: "#",
|
|
48
|
+
prPrefix: "▸#",
|
|
49
|
+
issue: null,
|
|
50
|
+
pr: null,
|
|
51
|
+
allRefs: [],
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Defensive shape guard mirroring evidence's normalize: missing/garbage input
|
|
57
|
+
* yields an empty snapshot; valid fields pass through.
|
|
58
|
+
* @param {any} raw
|
|
59
|
+
* @param {{ viewId:string }} fallback
|
|
60
|
+
* @returns {CodeRefsSnapshot}
|
|
61
|
+
*/
|
|
62
|
+
export function normalizeCodeRefsSnapshot(raw, fallback) {
|
|
63
|
+
const base = emptyCodeRefsSnapshot({ viewId: fallback.viewId });
|
|
64
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return base;
|
|
65
|
+
return {
|
|
66
|
+
...base,
|
|
67
|
+
...raw,
|
|
68
|
+
viewId: typeof raw.viewId === "string" ? raw.viewId : base.viewId,
|
|
69
|
+
provider: typeof raw.provider === "string" ? raw.provider : (raw.provider === null ? null : base.provider),
|
|
70
|
+
issuePrefix: typeof raw.issuePrefix === "string" ? raw.issuePrefix : base.issuePrefix,
|
|
71
|
+
prPrefix: typeof raw.prPrefix === "string" ? raw.prPrefix : base.prPrefix,
|
|
72
|
+
issue: isRefObject(raw.issue) ? raw.issue : null,
|
|
73
|
+
pr: isRefObject(raw.pr) ? raw.pr : null,
|
|
74
|
+
allRefs: Array.isArray(raw.allRefs) ? raw.allRefs.filter(isValidRefElement) : [],
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** @param {any} value @returns {boolean} */
|
|
79
|
+
function isRefObject(value) {
|
|
80
|
+
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* A valid allRefs element is a ref-shaped object whose kind is issue/pr and
|
|
85
|
+
* whose number is a positive integer.
|
|
86
|
+
* @param {any} value @returns {boolean}
|
|
87
|
+
*/
|
|
88
|
+
function isValidRefElement(value) {
|
|
89
|
+
return Boolean(
|
|
90
|
+
isRefObject(value) &&
|
|
91
|
+
(value.kind === "issue" || value.kind === "pr") &&
|
|
92
|
+
Number.isInteger(value.number) &&
|
|
93
|
+
value.number > 0
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** @param {string} root @param {string} viewId @returns {CodeRefsSnapshot} */
|
|
98
|
+
export function readCodeRefs(root, viewId) {
|
|
99
|
+
return normalizeCodeRefsSnapshot(readJson(P.codeRefsPath(root, viewId), null), { viewId });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** @param {string} root @param {CodeRefsSnapshot} snapshot @returns {CodeRefsSnapshot} */
|
|
103
|
+
export function writeCodeRefs(root, snapshot) {
|
|
104
|
+
const normalized = normalizeCodeRefsSnapshot(snapshot, { viewId: snapshot.viewId });
|
|
105
|
+
normalized.updatedAt = Date.now();
|
|
106
|
+
atomicWriteJson(P.codeRefsPath(root, normalized.viewId), normalized);
|
|
107
|
+
return normalized;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** @param {any} snapshot @returns {import("./types.mjs").CodeRefsSummary} */
|
|
111
|
+
export function summarizeCodeRefs(snapshot) {
|
|
112
|
+
const s = normalizeCodeRefsSnapshot(snapshot, { viewId: snapshot?.viewId ?? "" });
|
|
113
|
+
return {
|
|
114
|
+
provider: s.provider,
|
|
115
|
+
issuePrefix: s.issuePrefix,
|
|
116
|
+
prPrefix: s.prPrefix,
|
|
117
|
+
issue: s.issue,
|
|
118
|
+
pr: s.pr,
|
|
119
|
+
allRefs: s.allRefs,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Current branch of a working dir via `git branch --show-current`, cached per
|
|
125
|
+
* cwd for 60s. Best-effort like repo.mjs: off-repo or git failures yield null
|
|
126
|
+
* (also cached), but the TTL means a newly created branch shows up within a
|
|
127
|
+
* minute without needing an explicit cache clear.
|
|
128
|
+
* @type {Map<string, { at:number, branch:string|null }>}
|
|
129
|
+
*/
|
|
130
|
+
const branchCache = new Map();
|
|
131
|
+
const BRANCH_CACHE_TTL_MS = 60_000;
|
|
132
|
+
|
|
133
|
+
/** @param {string|null} cwd @returns {string|null} */
|
|
134
|
+
function currentBranch(cwd) {
|
|
135
|
+
if (typeof cwd !== "string" || !cwd) return null;
|
|
136
|
+
const cached = branchCache.get(cwd);
|
|
137
|
+
if (cached && Date.now() - cached.at < BRANCH_CACHE_TTL_MS) return cached.branch;
|
|
138
|
+
let branch = null;
|
|
139
|
+
try {
|
|
140
|
+
const out = execFileSync("git", ["-C", cwd, "branch", "--show-current"], {
|
|
141
|
+
encoding: "utf8",
|
|
142
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
143
|
+
timeout: 2000,
|
|
144
|
+
});
|
|
145
|
+
branch = out.trim() || null;
|
|
146
|
+
} catch {
|
|
147
|
+
// not a repo or git unavailable
|
|
148
|
+
}
|
|
149
|
+
branchCache.set(cwd, { at: Date.now(), branch });
|
|
150
|
+
return branch;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Extract issue/PR refs from view evidence and persist the per-view snapshot.
|
|
155
|
+
* The hook helper for every `writeEvidence` call site: never throws, writes only
|
|
156
|
+
* when the serialized ref content actually changed, and is disabled entirely
|
|
157
|
+
* by `AGENT_BOARD_CODE_REFS=off` (returns false without writing).
|
|
158
|
+
*
|
|
159
|
+
* `meta` is a parameter (callers pass `row.meta` / the runner's readMeta
|
|
160
|
+
* result) so this module never imports store.mjs. repoRoot falls back to
|
|
161
|
+
* meta.cwd when the view has no recorded repo root.
|
|
162
|
+
* @param {string} root
|
|
163
|
+
* @param {string} viewId
|
|
164
|
+
* @param {import("./types.mjs").EvidenceSnapshot|any} evidence
|
|
165
|
+
* @param {{repoRoot?: string|null, cwd?: string|null, worktreePath?: string|null}|null|undefined} meta
|
|
166
|
+
* @returns {boolean} true when extraction ran and the snapshot is current; false when off or failed.
|
|
167
|
+
*/
|
|
168
|
+
export function updateCodeRefsFromEvidence(root, viewId, evidence, meta) {
|
|
169
|
+
if (process.env.AGENT_BOARD_CODE_REFS === "off") return false;
|
|
170
|
+
try {
|
|
171
|
+
const hasCommands = Array.isArray(evidence?.commands) && evidence.commands.length > 0;
|
|
172
|
+
const hasAssistantEvidence = Array.isArray(evidence?.assistantEvidence) && evidence.assistantEvidence.length > 0;
|
|
173
|
+
if (!hasCommands && !hasAssistantEvidence) return false;
|
|
174
|
+
const repoRoot = meta?.repoRoot ?? meta?.cwd ?? null;
|
|
175
|
+
const remoteUrl = repoRoot ? gitRemoteUrl(repoRoot) : null;
|
|
176
|
+
const host = remoteUrl ? parseRemoteHost(remoteUrl) : null;
|
|
177
|
+
const repoUrl = remoteUrl ? parseRemotePath(remoteUrl) : null;
|
|
178
|
+
const { providers, errors } = loadProvidersWithErrors(root);
|
|
179
|
+
const provider = matchProvider(providers, host);
|
|
180
|
+
reportConfigErrors(root, viewId, evidence, errors);
|
|
181
|
+
const cwd = typeof meta?.cwd === "string" ? meta.cwd : null;
|
|
182
|
+
const worktreePath = typeof meta?.worktreePath === "string" ? meta.worktreePath : null;
|
|
183
|
+
const branch = currentBranch(cwd);
|
|
184
|
+
const input = buildEngineInput(evidence, { worktreePath, branch, repoUrl, host });
|
|
185
|
+
const result = extractCodeRefs(input, provider);
|
|
186
|
+
const existing = readCodeRefs(root, viewId);
|
|
187
|
+
// Carry forward earned refs per kind: job-runner resets view-level
|
|
188
|
+
// evidence at each run start, so a follow-up run whose events carry no
|
|
189
|
+
// signal for a kind must not null that kind's previously earned ref —
|
|
190
|
+
// absence of signal is not evidence of absence. (CR rounds 1-2)
|
|
191
|
+
const merged = mergeWithExisting(result, existing);
|
|
192
|
+
const next = {
|
|
193
|
+
version: 1,
|
|
194
|
+
viewId,
|
|
195
|
+
updatedAt: Date.now(),
|
|
196
|
+
provider: result.provider,
|
|
197
|
+
issuePrefix: provider.issuePrefix,
|
|
198
|
+
prPrefix: provider.prPrefix,
|
|
199
|
+
issue: merged.issue,
|
|
200
|
+
pr: merged.pr,
|
|
201
|
+
allRefs: merged.allRefs,
|
|
202
|
+
};
|
|
203
|
+
// Avoid churning the artifact (and its mtime) when the refs are unchanged.
|
|
204
|
+
if (contentOf(existing) === contentOf(next)) return true;
|
|
205
|
+
writeCodeRefs(root, next);
|
|
206
|
+
return true;
|
|
207
|
+
} catch (e) {
|
|
208
|
+
try {
|
|
209
|
+
appendDiagnostic(root, viewId, {
|
|
210
|
+
runId: evidence?.runId ?? null,
|
|
211
|
+
source: "evidence",
|
|
212
|
+
level: "error",
|
|
213
|
+
code: "code_refs_extract_failed",
|
|
214
|
+
message: `code-refs extraction failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
215
|
+
});
|
|
216
|
+
} catch {
|
|
217
|
+
// diagnostics must never break the evidence flow either
|
|
218
|
+
}
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** @param {any} s @returns {string} serialized ref content (updatedAt excluded) */
|
|
224
|
+
function contentOf(s) {
|
|
225
|
+
return JSON.stringify({
|
|
226
|
+
provider: s.provider,
|
|
227
|
+
issuePrefix: s.issuePrefix,
|
|
228
|
+
prPrefix: s.prPrefix,
|
|
229
|
+
issue: s.issue,
|
|
230
|
+
pr: s.pr,
|
|
231
|
+
allRefs: s.allRefs,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Merge a fresh extraction with the stored snapshot: a kind with no signal in
|
|
237
|
+
* the new extraction inherits the stored ref (per-kind carry-forward), and
|
|
238
|
+
* stored refs missing from the new allRefs are appended (deduped by
|
|
239
|
+
* kind+number, capped at 10).
|
|
240
|
+
* @param {import("./code-refs.mjs").CodeRefsResult} result
|
|
241
|
+
* @param {any} existing normalized snapshot
|
|
242
|
+
* @returns {{issue: any, pr: any, allRefs: any[]}}
|
|
243
|
+
*/
|
|
244
|
+
function mergeWithExisting(result, existing) {
|
|
245
|
+
const issue = result.issue ?? existing?.issue ?? null;
|
|
246
|
+
const pr = result.pr ?? existing?.pr ?? null;
|
|
247
|
+
const seen = new Set((result.allRefs ?? []).map((r) => `${r.kind}:${r.number}`));
|
|
248
|
+
const carried = [];
|
|
249
|
+
for (const r of [existing?.issue, existing?.pr, ...(Array.isArray(existing?.allRefs) ? existing.allRefs : [])]) {
|
|
250
|
+
if (!r || seen.has(`${r.kind}:${r.number}`)) continue;
|
|
251
|
+
seen.add(`${r.kind}:${r.number}`);
|
|
252
|
+
carried.push(r);
|
|
253
|
+
}
|
|
254
|
+
return { issue, pr, allRefs: [...(result.allRefs ?? []), ...carried].slice(0, 10) };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Last providers.json mtime per root for which a code_refs_config diagnostic was emitted. */
|
|
258
|
+
const reportedConfigMtime = new Map();
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Emit ONE `code_refs_config` diagnostic per distinct providers.json mtime so a
|
|
262
|
+
* broken config is surfaced without spamming every evidence write.
|
|
263
|
+
* @param {string} root
|
|
264
|
+
* @param {string} viewId
|
|
265
|
+
* @param {import("./types.mjs").EvidenceSnapshot|any} evidence
|
|
266
|
+
* @param {string[]} errors
|
|
267
|
+
*/
|
|
268
|
+
function reportConfigErrors(root, viewId, evidence, errors) {
|
|
269
|
+
if (errors.length === 0) return;
|
|
270
|
+
let mtimeMs = null;
|
|
271
|
+
try {
|
|
272
|
+
mtimeMs = statSync(P.providersPath(root)).mtimeMs;
|
|
273
|
+
} catch {
|
|
274
|
+
// file disappeared — nothing to report against
|
|
275
|
+
}
|
|
276
|
+
if (reportedConfigMtime.get(root) === mtimeMs) return;
|
|
277
|
+
reportedConfigMtime.set(root, mtimeMs);
|
|
278
|
+
try {
|
|
279
|
+
appendDiagnostic(root, viewId, {
|
|
280
|
+
runId: evidence?.runId ?? null,
|
|
281
|
+
source: "code-refs",
|
|
282
|
+
level: "warn",
|
|
283
|
+
code: "code_refs_config",
|
|
284
|
+
message: "providers.json has invalid entries",
|
|
285
|
+
details: { errors },
|
|
286
|
+
});
|
|
287
|
+
} catch {
|
|
288
|
+
// diagnostics must never break the evidence flow either
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Compose the pure engine input from evidence: the last 200 commands (each
|
|
294
|
+
* truncated to 4000 chars) plus the last 20 assistant claim texts.
|
|
295
|
+
* @param {any} evidence
|
|
296
|
+
* @param {{ worktreePath: string|null, branch: string|null, repoUrl: string|null, host: string|null }} ctx
|
|
297
|
+
*/
|
|
298
|
+
function buildEngineInput(evidence, ctx) {
|
|
299
|
+
const rawCommands = Array.isArray(evidence?.commands) ? evidence.commands : [];
|
|
300
|
+
const commands = rawCommands.slice(-200).map((cmd) => {
|
|
301
|
+
if (cmd && typeof cmd.command === "string" && cmd.command.length > 4000) {
|
|
302
|
+
return { ...cmd, command: cmd.command.slice(0, 4000) };
|
|
303
|
+
}
|
|
304
|
+
return cmd;
|
|
305
|
+
});
|
|
306
|
+
const claims = Array.isArray(evidence?.assistantEvidence) ? evidence.assistantEvidence : [];
|
|
307
|
+
const assistantTexts = claims
|
|
308
|
+
.slice(-20)
|
|
309
|
+
.map((claim) => (claim && typeof claim.text === "string" ? claim.text : ""))
|
|
310
|
+
.filter((text) => text !== "");
|
|
311
|
+
return { commands, assistantTexts, worktreePath: ctx.worktreePath, branch: ctx.branch, repoUrl: ctx.repoUrl, host: ctx.host };
|
|
312
|
+
}
|