@lchase/kiln 0.3.0 → 0.4.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 +10 -6
- package/assets/serve.css +404 -185
- package/assets/serve.js +361 -0
- package/dist/attention.js +254 -0
- package/dist/board.js +326 -0
- package/dist/cli.js +30 -6
- package/dist/config.js +3 -1
- package/dist/driver.js +299 -25
- package/dist/reconcile.js +59 -20
- package/dist/reliability.js +441 -0
- package/dist/report.js +108 -4
- package/dist/schemas.js +14 -1
- package/dist/serve-page.js +295 -159
- package/dist/serve.js +36 -14
- package/dist/workflow.js +67 -0
- package/package.json +1 -1
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync, } from "node:fs";
|
|
4
|
+
import { dirname, join, resolve, sep } from "node:path";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { jobBasename } from "./paths.js";
|
|
7
|
+
import { FAILURE_REASONS } from "./reconcile.js";
|
|
8
|
+
/**
|
|
9
|
+
* Attempt ledger under `<kilnDir>/attempts/<jobId>.json`.
|
|
10
|
+
* Job files stay free of stored state. Tick, startup, and `kiln retry` are
|
|
11
|
+
* the only writers. A second pass that sees the same run id does not append.
|
|
12
|
+
*
|
|
13
|
+
* Failure backoff follows Symphony's failure rule, adapted to kiln attempts:
|
|
14
|
+
* `min(10s × 2^(n-1), 5 minutes)` where n is the attempt that just failed.
|
|
15
|
+
*/
|
|
16
|
+
export const RETRY_BASE_MS = 10_000;
|
|
17
|
+
export const MAX_RETRY_BACKOFF_MS = 5 * 60 * 1000;
|
|
18
|
+
const AttemptSchema = z.object({
|
|
19
|
+
n: z.number().int().min(1),
|
|
20
|
+
at: z.string().min(1),
|
|
21
|
+
reason: z.enum(FAILURE_REASONS),
|
|
22
|
+
runId: z.string().min(1).optional(),
|
|
23
|
+
pid: z.number().int().nonnegative().optional(),
|
|
24
|
+
detail: z.string().min(1).optional(),
|
|
25
|
+
});
|
|
26
|
+
const LedgerSchema = z.object({
|
|
27
|
+
jobId: z.string().min(1),
|
|
28
|
+
attempts: z.array(AttemptSchema),
|
|
29
|
+
nextRetryAt: z.string().min(1).optional(),
|
|
30
|
+
/** Runs a manual retry has retired. Observe skips them. */
|
|
31
|
+
ignoredRunIds: z.array(z.string().min(1)).optional(),
|
|
32
|
+
});
|
|
33
|
+
const RUN_ID = /^[A-Za-z0-9][A-Za-z0-9._+-]*$/;
|
|
34
|
+
/** Elapsed time must be strictly greater than the timeout. `<= 0` disables stall detection. */
|
|
35
|
+
export function isStalled(activityMs, nowMs, timeoutMs) {
|
|
36
|
+
if (timeoutMs <= 0 || activityMs === undefined)
|
|
37
|
+
return false;
|
|
38
|
+
return nowMs - activityMs > timeoutMs;
|
|
39
|
+
}
|
|
40
|
+
/** Delay before the next launch after `attempt` failures. Attempt 1 waits 10s. */
|
|
41
|
+
export function retryDelayMs(attempt) {
|
|
42
|
+
const n = Math.max(1, Math.floor(attempt));
|
|
43
|
+
const shift = Math.min(n - 1, 20);
|
|
44
|
+
return Math.min(RETRY_BASE_MS * 2 ** shift, MAX_RETRY_BACKOFF_MS);
|
|
45
|
+
}
|
|
46
|
+
export function attemptLedgerPath(kilnDir, jobId) {
|
|
47
|
+
return join(kilnDir, "attempts", `${jobBasename(jobId)}.json`);
|
|
48
|
+
}
|
|
49
|
+
export function readAttemptLedger(kilnDir, jobId) {
|
|
50
|
+
const path = attemptLedgerPath(kilnDir, jobId);
|
|
51
|
+
if (!existsSync(path))
|
|
52
|
+
return undefined;
|
|
53
|
+
try {
|
|
54
|
+
const parsed = LedgerSchema.parse(JSON.parse(readFileSync(path, "utf8")));
|
|
55
|
+
if (parsed.jobId !== jobId)
|
|
56
|
+
return "corrupt";
|
|
57
|
+
return parsed;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return "corrupt";
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Clear the attempt budget and hide `runId` so the next tick can launch.
|
|
65
|
+
* A corrupt ledger is replaced: this is the explicit recovery path.
|
|
66
|
+
*/
|
|
67
|
+
export function resetAttemptLedger(kilnDir, jobId, runId) {
|
|
68
|
+
const current = readAttemptLedger(kilnDir, jobId);
|
|
69
|
+
const ignored = new Set(current && current !== "corrupt" ? (current.ignoredRunIds ?? []) : []);
|
|
70
|
+
if (runId)
|
|
71
|
+
ignored.add(runId);
|
|
72
|
+
const ledger = {
|
|
73
|
+
jobId,
|
|
74
|
+
attempts: [],
|
|
75
|
+
...(ignored.size > 0 ? { ignoredRunIds: [...ignored] } : {}),
|
|
76
|
+
};
|
|
77
|
+
writeLedger(kilnDir, ledger);
|
|
78
|
+
return ledger;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Stall, orphan, crash, and non-zero-exit handling for one observation.
|
|
82
|
+
* Caller holds the tick lock. Safe to run twice: a run id is recorded once,
|
|
83
|
+
* and worktree cleanup no-ops once the tree is gone or must be kept.
|
|
84
|
+
*/
|
|
85
|
+
export function recoverRuns(config, jobs, options) {
|
|
86
|
+
const notes = [];
|
|
87
|
+
for (const job of jobs) {
|
|
88
|
+
if (job.error)
|
|
89
|
+
continue;
|
|
90
|
+
const existing = readAttemptLedger(config.kilnDir, job.jobId);
|
|
91
|
+
if (existing === "corrupt")
|
|
92
|
+
continue;
|
|
93
|
+
const pending = pendingFailure(job, config, options);
|
|
94
|
+
if (!pending)
|
|
95
|
+
continue;
|
|
96
|
+
if (pending.killPid !== undefined && !killTree(pending.killPid))
|
|
97
|
+
continue;
|
|
98
|
+
const recorded = recordFailure(config, job.jobId, existing, pending, options.now);
|
|
99
|
+
if (recorded.created) {
|
|
100
|
+
appendLog(job.logPath, recorded.ledger.attempts.at(-1));
|
|
101
|
+
notes.push({
|
|
102
|
+
jobId: job.jobId,
|
|
103
|
+
kind: kindFor(pending.reason),
|
|
104
|
+
reason: pending.reason,
|
|
105
|
+
attempt: recorded.ledger.attempts.length,
|
|
106
|
+
...(recorded.ledger.nextRetryAt ? { nextRetryAt: recorded.ledger.nextRetryAt } : {}),
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
const checkout = job.run?.checkout ?? config.repos[job.repo]?.path;
|
|
110
|
+
const runId = pending.runId ?? job.run?.runId;
|
|
111
|
+
if (checkout && runId)
|
|
112
|
+
cleanupRunWorktree(checkout, runId);
|
|
113
|
+
}
|
|
114
|
+
return notes;
|
|
115
|
+
}
|
|
116
|
+
function pendingFailure(job, config, options) {
|
|
117
|
+
if (job.run?.gateDecision === "reject")
|
|
118
|
+
return undefined;
|
|
119
|
+
if (job.run?.status === "ok")
|
|
120
|
+
return undefined;
|
|
121
|
+
if (job.run?.status === "suspended")
|
|
122
|
+
return undefined;
|
|
123
|
+
if (job.run?.status === "failed" && job.run.finishedReason === "cancelled")
|
|
124
|
+
return undefined;
|
|
125
|
+
const now = options.now.getTime();
|
|
126
|
+
const launchingLive = job.launching && job.launchPid !== undefined && isPidAlive(job.launchPid);
|
|
127
|
+
if (launchingLive && job.launchPid !== undefined && isStalled(job.launchActivityMs, now, config.stallTimeoutMs)) {
|
|
128
|
+
return {
|
|
129
|
+
reason: "stalled",
|
|
130
|
+
pid: job.launchPid,
|
|
131
|
+
killPid: job.launchPid,
|
|
132
|
+
detail: "no yak event or log output",
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
if (launchingLive)
|
|
136
|
+
return undefined;
|
|
137
|
+
const run = job.run;
|
|
138
|
+
const runLive = run?.status === "alive" && run.gateDecision !== "approve" && run.pid !== undefined && isPidAlive(run.pid);
|
|
139
|
+
if (runLive && run?.pid !== undefined && isStalled(run.activityMs, now, config.stallTimeoutMs)) {
|
|
140
|
+
return {
|
|
141
|
+
reason: "stalled",
|
|
142
|
+
runId: run.runId,
|
|
143
|
+
pid: run.pid,
|
|
144
|
+
killPid: run.pid,
|
|
145
|
+
detail: "no yak event or log output",
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
if (runLive)
|
|
149
|
+
return undefined;
|
|
150
|
+
if (run && (run.status === "dead" || (run.status === "alive" && run.pid !== undefined && !isPidAlive(run.pid)))) {
|
|
151
|
+
return options.startup
|
|
152
|
+
? { reason: "orphaned", runId: run.runId, pid: run.pid, detail: "process gone at startup" }
|
|
153
|
+
: { reason: "crashed", runId: run.runId, pid: run.pid, detail: "process exited" };
|
|
154
|
+
}
|
|
155
|
+
if (run?.status === "failed") {
|
|
156
|
+
return { reason: "failed", runId: run.runId, pid: run.pid, detail: "non-zero exit" };
|
|
157
|
+
}
|
|
158
|
+
if (job.launchFailed) {
|
|
159
|
+
return {
|
|
160
|
+
reason: "crashed",
|
|
161
|
+
...(job.launchPid !== undefined ? { pid: job.launchPid } : {}),
|
|
162
|
+
detail: "launch exited before a journal",
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
return undefined;
|
|
166
|
+
}
|
|
167
|
+
function recordFailure(config, jobId, existing, pending, now) {
|
|
168
|
+
const ledger = existing ?? { jobId, attempts: [] };
|
|
169
|
+
if (alreadyRecorded(ledger, pending))
|
|
170
|
+
return { ledger, created: false };
|
|
171
|
+
const attempt = {
|
|
172
|
+
n: ledger.attempts.length + 1,
|
|
173
|
+
at: now.toISOString(),
|
|
174
|
+
reason: pending.reason,
|
|
175
|
+
...(pending.runId ? { runId: pending.runId } : {}),
|
|
176
|
+
...(pending.pid !== undefined ? { pid: pending.pid } : {}),
|
|
177
|
+
detail: pending.detail,
|
|
178
|
+
};
|
|
179
|
+
const attempts = [...ledger.attempts, attempt];
|
|
180
|
+
const exhausted = attempts.length >= config.maxAttempts;
|
|
181
|
+
const next = exhausted ? undefined : new Date(now.getTime() + retryDelayMs(attempts.length)).toISOString();
|
|
182
|
+
const written = {
|
|
183
|
+
jobId,
|
|
184
|
+
attempts,
|
|
185
|
+
...(next ? { nextRetryAt: next } : {}),
|
|
186
|
+
...(ledger.ignoredRunIds && ledger.ignoredRunIds.length > 0 ? { ignoredRunIds: ledger.ignoredRunIds } : {}),
|
|
187
|
+
};
|
|
188
|
+
writeLedger(config.kilnDir, written);
|
|
189
|
+
return { ledger: written, created: true };
|
|
190
|
+
}
|
|
191
|
+
function alreadyRecorded(ledger, pending) {
|
|
192
|
+
if (pending.runId)
|
|
193
|
+
return ledger.attempts.some((attempt) => attempt.runId === pending.runId);
|
|
194
|
+
if (pending.pid !== undefined) {
|
|
195
|
+
return ledger.attempts.some((attempt) => attempt.runId === undefined && attempt.pid === pending.pid);
|
|
196
|
+
}
|
|
197
|
+
return ledger.attempts.some((attempt) => attempt.runId === undefined && attempt.pid === undefined && attempt.reason === pending.reason);
|
|
198
|
+
}
|
|
199
|
+
function kindFor(reason) {
|
|
200
|
+
if (reason === "stalled")
|
|
201
|
+
return "stall";
|
|
202
|
+
if (reason === "orphaned")
|
|
203
|
+
return "orphan";
|
|
204
|
+
if (reason === "crashed")
|
|
205
|
+
return "crash";
|
|
206
|
+
return "fail";
|
|
207
|
+
}
|
|
208
|
+
function appendLog(logPath, attempt) {
|
|
209
|
+
if (!logPath || !attempt)
|
|
210
|
+
return;
|
|
211
|
+
try {
|
|
212
|
+
mkdirSync(dirname(logPath), { recursive: true });
|
|
213
|
+
appendFileSync(logPath, `[kiln] ${attempt.reason}: ${attempt.detail ?? "failed"}\n`);
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
// The ledger is the record. A missing log must not drop the attempt.
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
function writeLedger(kilnDir, ledger) {
|
|
220
|
+
const path = attemptLedgerPath(kilnDir, ledger.jobId);
|
|
221
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
222
|
+
const tmp = `${path}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
223
|
+
writeFileSync(tmp, `${JSON.stringify(LedgerSchema.parse(ledger), null, 2)}\n`);
|
|
224
|
+
renameSync(tmp, path);
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Drop a failed run's worktree only when that loses no commits.
|
|
228
|
+
* A branch with commits that are not on HEAD and not on a remote is kept,
|
|
229
|
+
* and so is a dirty worktree. The checkout's own worktree is not removed.
|
|
230
|
+
*/
|
|
231
|
+
export function cleanupRunWorktree(checkout, runId) {
|
|
232
|
+
if (!safeRunId(runId))
|
|
233
|
+
return "kept";
|
|
234
|
+
const root = resolve(checkout);
|
|
235
|
+
const branch = `yak/${runId}`;
|
|
236
|
+
const worktree = resolve(root, ".yak", "worktrees", runId);
|
|
237
|
+
const worktreeRoot = resolve(root, ".yak", "worktrees");
|
|
238
|
+
if (worktree === root || !worktree.startsWith(`${worktreeRoot}${sep}`))
|
|
239
|
+
return "kept";
|
|
240
|
+
const branchExists = gitOk(root, ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`]);
|
|
241
|
+
const worktreeExists = existsSync(worktree);
|
|
242
|
+
if (!branchExists && !worktreeExists)
|
|
243
|
+
return "absent";
|
|
244
|
+
if (checkedOut(root, branch))
|
|
245
|
+
return "kept";
|
|
246
|
+
if (branchExists && hasUnpushedCommits(root, branch))
|
|
247
|
+
return "kept";
|
|
248
|
+
if (worktreeExists && worktreeDirty(worktree))
|
|
249
|
+
return "kept";
|
|
250
|
+
if (worktreeExists && worktreeListed(root, worktree)) {
|
|
251
|
+
gitOk(root, ["worktree", "remove", worktree]);
|
|
252
|
+
}
|
|
253
|
+
if (existsSync(worktree))
|
|
254
|
+
return "kept";
|
|
255
|
+
if (branchExists)
|
|
256
|
+
gitOk(root, ["branch", "-D", branch]);
|
|
257
|
+
const stillBranch = gitOk(root, ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`]);
|
|
258
|
+
return existsSync(worktree) || stillBranch ? "kept" : "removed";
|
|
259
|
+
}
|
|
260
|
+
function safeRunId(runId) {
|
|
261
|
+
return RUN_ID.test(runId) && !runId.includes("..");
|
|
262
|
+
}
|
|
263
|
+
function checkedOut(checkout, branch) {
|
|
264
|
+
const result = git(checkout, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
265
|
+
return result.status === 0 && result.stdout.trim() === branch;
|
|
266
|
+
}
|
|
267
|
+
/** Commits on the branch that are neither in HEAD nor on any remote. Failure keeps the branch. */
|
|
268
|
+
function hasUnpushedCommits(checkout, branch) {
|
|
269
|
+
const result = git(checkout, ["rev-list", "--count", branch, "--not", "HEAD", "--not", "--remotes"]);
|
|
270
|
+
if (result.status !== 0)
|
|
271
|
+
return true;
|
|
272
|
+
const count = Number(result.stdout.trim());
|
|
273
|
+
return !Number.isInteger(count) || count > 0;
|
|
274
|
+
}
|
|
275
|
+
function worktreeDirty(worktree) {
|
|
276
|
+
const result = git(worktree, ["status", "--porcelain"]);
|
|
277
|
+
if (result.status !== 0)
|
|
278
|
+
return true;
|
|
279
|
+
return result.stdout.trim().length > 0;
|
|
280
|
+
}
|
|
281
|
+
function worktreeListed(checkout, worktree) {
|
|
282
|
+
const result = git(checkout, ["worktree", "list", "--porcelain"]);
|
|
283
|
+
if (result.status !== 0)
|
|
284
|
+
return false;
|
|
285
|
+
return result.stdout.split("\n").some((line) => line.startsWith("worktree ") && resolve(line.slice("worktree ".length)) === worktree);
|
|
286
|
+
}
|
|
287
|
+
function gitOk(cwd, args) {
|
|
288
|
+
return git(cwd, args).status === 0;
|
|
289
|
+
}
|
|
290
|
+
function git(cwd, args) {
|
|
291
|
+
const result = spawnSync("git", args, {
|
|
292
|
+
cwd,
|
|
293
|
+
encoding: "utf8",
|
|
294
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
|
|
295
|
+
});
|
|
296
|
+
return { status: result.status ?? 1, stdout: result.stdout ?? "" };
|
|
297
|
+
}
|
|
298
|
+
const PROTECTED = new Set([process.pid, process.ppid].filter((pid) => Number.isInteger(pid) && pid > 1));
|
|
299
|
+
const KILL_WAIT_MS = 2_000;
|
|
300
|
+
const KILL_POLL_MS = 20;
|
|
301
|
+
/**
|
|
302
|
+
* Kill `pid` and its children. Returns true once the pid is gone or a zombie.
|
|
303
|
+
* SIGKILL is not instant: a single check can still see the old state, and a
|
|
304
|
+
* zombie answers `kill(pid, 0)` until it is reaped, so this polls.
|
|
305
|
+
*/
|
|
306
|
+
export function killTree(pid) {
|
|
307
|
+
if (!Number.isInteger(pid) || pid <= 1 || PROTECTED.has(pid))
|
|
308
|
+
return false;
|
|
309
|
+
const deadline = Date.now() + KILL_WAIT_MS;
|
|
310
|
+
do {
|
|
311
|
+
signalTree(pid);
|
|
312
|
+
if (!isPidAlive(pid))
|
|
313
|
+
return true;
|
|
314
|
+
sleepMs(KILL_POLL_MS);
|
|
315
|
+
} while (Date.now() < deadline);
|
|
316
|
+
signalTree(pid);
|
|
317
|
+
return !isPidAlive(pid);
|
|
318
|
+
}
|
|
319
|
+
function signalTree(pid) {
|
|
320
|
+
const victims = new Set();
|
|
321
|
+
const stack = [pid];
|
|
322
|
+
while (stack.length > 0) {
|
|
323
|
+
const current = stack.pop();
|
|
324
|
+
if (current === undefined || victims.has(current) || PROTECTED.has(current) || current <= 1)
|
|
325
|
+
continue;
|
|
326
|
+
victims.add(current);
|
|
327
|
+
for (const child of childPids(current))
|
|
328
|
+
stack.push(child);
|
|
329
|
+
}
|
|
330
|
+
const group = processGroup(pid);
|
|
331
|
+
if (group === pid && !PROTECTED.has(group))
|
|
332
|
+
signal(-pid, "SIGKILL");
|
|
333
|
+
for (const victim of victims)
|
|
334
|
+
signal(victim, "SIGKILL");
|
|
335
|
+
}
|
|
336
|
+
function childPids(pid) {
|
|
337
|
+
try {
|
|
338
|
+
const text = readFileSync(`/proc/${pid}/task/${pid}/children`, "utf8").trim();
|
|
339
|
+
if (text) {
|
|
340
|
+
return text
|
|
341
|
+
.split(/\s+/)
|
|
342
|
+
.map((part) => Number(part))
|
|
343
|
+
.filter((child) => Number.isInteger(child) && child > 1);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
catch {
|
|
347
|
+
// Fall through to a PPid scan. Some kernels leave `children` empty.
|
|
348
|
+
}
|
|
349
|
+
return childrenByPpid(pid);
|
|
350
|
+
}
|
|
351
|
+
function childrenByPpid(parent) {
|
|
352
|
+
let entries;
|
|
353
|
+
try {
|
|
354
|
+
entries = readdirSync("/proc");
|
|
355
|
+
}
|
|
356
|
+
catch {
|
|
357
|
+
return [];
|
|
358
|
+
}
|
|
359
|
+
const found = [];
|
|
360
|
+
for (const entry of entries) {
|
|
361
|
+
if (!/^\d+$/.test(entry))
|
|
362
|
+
continue;
|
|
363
|
+
const child = Number(entry);
|
|
364
|
+
if (child <= 1 || child === parent)
|
|
365
|
+
continue;
|
|
366
|
+
if (Number(statusValue(child, "PPid")) === parent)
|
|
367
|
+
found.push(child);
|
|
368
|
+
}
|
|
369
|
+
return found;
|
|
370
|
+
}
|
|
371
|
+
function processGroup(pid) {
|
|
372
|
+
const state = procFields(pid);
|
|
373
|
+
const group = state ? Number(state[2]) : Number.NaN;
|
|
374
|
+
return Number.isInteger(group) ? group : undefined;
|
|
375
|
+
}
|
|
376
|
+
function procState(pid) {
|
|
377
|
+
return procFields(pid)?.[0] ?? statusValue(pid, "State")?.slice(0, 1);
|
|
378
|
+
}
|
|
379
|
+
function statusValue(pid, key) {
|
|
380
|
+
try {
|
|
381
|
+
const text = readFileSync(`/proc/${pid}/status`, "utf8");
|
|
382
|
+
const line = text.split("\n").find((entry) => entry.startsWith(`${key}:`));
|
|
383
|
+
return line?.slice(key.length + 1).trim();
|
|
384
|
+
}
|
|
385
|
+
catch {
|
|
386
|
+
return undefined;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
/** Fields of `/proc/pid/stat` after the comm, starting at `state`. */
|
|
390
|
+
function procFields(pid) {
|
|
391
|
+
try {
|
|
392
|
+
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
393
|
+
const end = stat.lastIndexOf(")");
|
|
394
|
+
if (end < 0)
|
|
395
|
+
return undefined;
|
|
396
|
+
return stat.slice(end + 2).split(" ");
|
|
397
|
+
}
|
|
398
|
+
catch {
|
|
399
|
+
return undefined;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
function signal(pid, sig) {
|
|
403
|
+
try {
|
|
404
|
+
process.kill(pid, sig);
|
|
405
|
+
}
|
|
406
|
+
catch {
|
|
407
|
+
// Already gone, or not our process group.
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* True when `pid` is still running. A zombie is not alive: `kill(pid, 0)`
|
|
412
|
+
* succeeds until the parent reaps it, which would keep a killed run in flight.
|
|
413
|
+
* EPERM means the pid exists and we cannot inspect it, so it still counts.
|
|
414
|
+
*/
|
|
415
|
+
export function isPidAlive(pid) {
|
|
416
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
417
|
+
return false;
|
|
418
|
+
try {
|
|
419
|
+
process.kill(pid, 0);
|
|
420
|
+
}
|
|
421
|
+
catch (error) {
|
|
422
|
+
return isErrno(error, "EPERM");
|
|
423
|
+
}
|
|
424
|
+
const state = procState(pid);
|
|
425
|
+
return state !== "Z" && state !== "X";
|
|
426
|
+
}
|
|
427
|
+
function sleepMs(ms) {
|
|
428
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
429
|
+
}
|
|
430
|
+
function isErrno(error, code) {
|
|
431
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
|
432
|
+
}
|
|
433
|
+
/** Latest mtime, folded with journal event times by the driver. */
|
|
434
|
+
export function fileMtimeMs(path) {
|
|
435
|
+
try {
|
|
436
|
+
return statSync(path).mtimeMs;
|
|
437
|
+
}
|
|
438
|
+
catch {
|
|
439
|
+
return undefined;
|
|
440
|
+
}
|
|
441
|
+
}
|
package/dist/report.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
|
+
import { readBoardFiles, readReviewRounds } from "./board.js";
|
|
4
5
|
import { INVESTIGATION_WORKFLOW } from "./findings.js";
|
|
5
6
|
import { inFlightCount, isDone, isLanded, jobState, } from "./reconcile.js";
|
|
6
7
|
export function shellQuote(value) {
|
|
@@ -29,6 +30,52 @@ export function gateAnswerCommand(checkout, runId, gate, choice, title) {
|
|
|
29
30
|
const titlePrefix = title ? `KILN_TASK_TITLE=${shellQuote(title)} ` : "";
|
|
30
31
|
return `cd ${shellQuote(checkout)} && mkdir -p ${pendingDir} && printf '%s\\n' ${shellQuote(answer)} > ${answerFile} && ${titlePrefix}${yakInvocation("resume", runId)}`;
|
|
31
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* Mark a workflow's leaf steps from yak journal events.
|
|
35
|
+
* A later `step.started` reopens a step that already completed (the next loop round).
|
|
36
|
+
* Events whose id is not in the plan are ignored when the plan is non-empty,
|
|
37
|
+
* so a loop container does not sit beside its body. An empty plan uses
|
|
38
|
+
* first-seen event order.
|
|
39
|
+
*/
|
|
40
|
+
export function stepProgress(plan, events) {
|
|
41
|
+
const order = [...plan];
|
|
42
|
+
const phase = new Map();
|
|
43
|
+
for (const id of order)
|
|
44
|
+
phase.set(id, "pending");
|
|
45
|
+
for (const event of events) {
|
|
46
|
+
if (!event.stepId)
|
|
47
|
+
continue;
|
|
48
|
+
if (!phase.has(event.stepId)) {
|
|
49
|
+
if (plan.length > 0)
|
|
50
|
+
continue;
|
|
51
|
+
order.push(event.stepId);
|
|
52
|
+
phase.set(event.stepId, "pending");
|
|
53
|
+
}
|
|
54
|
+
if (event.t === "step.started")
|
|
55
|
+
phase.set(event.stepId, "current");
|
|
56
|
+
else if (event.t === "step.completed")
|
|
57
|
+
phase.set(event.stepId, "done");
|
|
58
|
+
else if (event.t === "step.failed")
|
|
59
|
+
phase.set(event.stepId, "failed");
|
|
60
|
+
}
|
|
61
|
+
return order.map((id) => ({ id, phase: phase.get(id) ?? "pending" }));
|
|
62
|
+
}
|
|
63
|
+
/** Detail of the latest step that failed and has not started or completed since. */
|
|
64
|
+
export function latestStepFailure(events) {
|
|
65
|
+
const settled = new Set();
|
|
66
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
67
|
+
const event = events[index];
|
|
68
|
+
if (!event)
|
|
69
|
+
continue;
|
|
70
|
+
if (event.t === "step.started" || event.t === "step.completed") {
|
|
71
|
+
settled.add(event.stepId);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (event.t === "step.failed" && !settled.has(event.stepId) && event.detail)
|
|
75
|
+
return event.detail;
|
|
76
|
+
}
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
32
79
|
/**
|
|
33
80
|
* Derived state for every job, or for one id.
|
|
34
81
|
* Throws `No job <id>` when that id is not on disk, same as `kiln status`.
|
|
@@ -76,6 +123,16 @@ export function statusDetails(job) {
|
|
|
76
123
|
lines.push(`log ${job.logPath}`);
|
|
77
124
|
if (job.state === "failed" && job.launchError)
|
|
78
125
|
lines.push(job.launchError);
|
|
126
|
+
if (job.ledgerError)
|
|
127
|
+
lines.push(job.ledgerError);
|
|
128
|
+
if (job.attemptCount > 0)
|
|
129
|
+
lines.push(`attempts ${job.attemptCount}`);
|
|
130
|
+
if (job.lastFailureReason)
|
|
131
|
+
lines.push(`reason ${job.lastFailureReason}`);
|
|
132
|
+
if (job.nextRetryAt)
|
|
133
|
+
lines.push(`next retry ${job.nextRetryAt}`);
|
|
134
|
+
if (job.state === "dead" && job.attemptCount > 0)
|
|
135
|
+
lines.push(`retry: kiln retry ${job.jobId}`);
|
|
79
136
|
if (job.noReadableApproval)
|
|
80
137
|
lines.push("no readable approval decision");
|
|
81
138
|
if (job.cancelCommand)
|
|
@@ -122,6 +179,10 @@ function toStatusJob(job, observation) {
|
|
|
122
179
|
...(job.logPath ? { logPath: job.logPath } : {}),
|
|
123
180
|
...(job.findingsPath ? { findingsPath: job.findingsPath } : {}),
|
|
124
181
|
launchFailed: job.launchFailed,
|
|
182
|
+
attemptCount: job.attemptCount ?? 0,
|
|
183
|
+
maxAttempts: observation.maxAttempts,
|
|
184
|
+
...(job.lastFailureReason ? { lastFailureReason: job.lastFailureReason } : {}),
|
|
185
|
+
...(job.nextRetryAt ? { nextRetryAt: job.nextRetryAt } : {}),
|
|
125
186
|
invalidReason: job.error,
|
|
126
187
|
prPending: false,
|
|
127
188
|
prClosedUnmerged: false,
|
|
@@ -129,6 +190,10 @@ function toStatusJob(job, observation) {
|
|
|
129
190
|
gates: [],
|
|
130
191
|
canApprove: false,
|
|
131
192
|
canReject: false,
|
|
193
|
+
steps: progressFor(job),
|
|
194
|
+
...(job.createdAt ? { createdAt: job.createdAt } : {}),
|
|
195
|
+
...(job.logTail ? { logTail: job.logTail } : {}),
|
|
196
|
+
...boardFields(job),
|
|
132
197
|
};
|
|
133
198
|
}
|
|
134
199
|
const state = jobState(job, observation);
|
|
@@ -136,6 +201,7 @@ function toStatusJob(job, observation) {
|
|
|
136
201
|
const prNumber = job.delivery?.prNumber;
|
|
137
202
|
const runId = job.run?.runId;
|
|
138
203
|
const showBranch = (state === "done" || state === "in-review") && runId !== undefined;
|
|
204
|
+
const failureDetail = job.run?.stepEvents ? latestStepFailure(job.run.stepEvents) : undefined;
|
|
139
205
|
return {
|
|
140
206
|
jobId: job.jobId,
|
|
141
207
|
title: job.title,
|
|
@@ -153,20 +219,54 @@ function toStatusJob(job, observation) {
|
|
|
153
219
|
...(job.specPath ? { specPath: job.specPath } : {}),
|
|
154
220
|
...(job.findingsPath ? { findingsPath: job.findingsPath } : {}),
|
|
155
221
|
launchFailed: job.launchFailed,
|
|
222
|
+
attemptCount: job.attemptCount ?? 0,
|
|
223
|
+
maxAttempts: observation.maxAttempts,
|
|
224
|
+
...(job.lastFailureReason ? { lastFailureReason: job.lastFailureReason } : {}),
|
|
225
|
+
...(job.nextRetryAt && !job.retriesExhausted ? { nextRetryAt: job.nextRetryAt } : {}),
|
|
226
|
+
...(job.ledgerError ? { ledgerError: job.ledgerError } : {}),
|
|
156
227
|
...(job.launchError ? { launchError: job.launchError } : {}),
|
|
157
228
|
...(job.delivery?.error ? { deliveryError: job.delivery.error } : {}),
|
|
158
229
|
...(job.delivery?.viewError ? { viewError: job.delivery.viewError } : {}),
|
|
159
230
|
prPending: state === "in-review" && !prNumber,
|
|
160
231
|
prClosedUnmerged: job.delivery?.prState === "CLOSED",
|
|
161
232
|
noReadableApproval: state === "failed" && job.run?.status === "ok" && !job.run.gateDecision,
|
|
162
|
-
...(
|
|
233
|
+
...(job.run && (job.run.status === "dead" || state === "dead")
|
|
163
234
|
? { cancelCommand: `fix: cd ${shellQuote(job.run.checkout)} && ${yakInvocation("cancel", job.run.runId)}` }
|
|
164
235
|
: {}),
|
|
165
236
|
gates,
|
|
166
237
|
canApprove: gates.some((gate) => gate.choices.includes("approve")),
|
|
167
238
|
canReject: gates.some((gate) => gate.choices.includes("reject")),
|
|
239
|
+
steps: progressFor(job),
|
|
240
|
+
...(job.createdAt ? { createdAt: job.createdAt } : {}),
|
|
241
|
+
...(job.run?.startedAt ? { startedAt: job.run.startedAt } : {}),
|
|
242
|
+
...(job.run?.finishedAt ? { finishedAt: job.run.finishedAt } : {}),
|
|
243
|
+
...(job.logTail ? { logTail: job.logTail } : {}),
|
|
244
|
+
...(job.diffSummary ? { diffSummary: job.diffSummary } : {}),
|
|
245
|
+
...(failureDetail ? { failureDetail } : {}),
|
|
246
|
+
...boardFields(job),
|
|
168
247
|
};
|
|
169
248
|
}
|
|
249
|
+
function boardFields(job) {
|
|
250
|
+
const runDir = job.run ? join(job.run.checkout, ".runs", job.run.runId) : undefined;
|
|
251
|
+
const rounds = readReviewRounds(runDir);
|
|
252
|
+
const files = readBoardFiles({
|
|
253
|
+
...(runDir ? { runDir } : {}),
|
|
254
|
+
...(job.specPath ? { specPath: job.specPath } : {}),
|
|
255
|
+
...(job.findingsPath ? { findingsPath: job.findingsPath } : {}),
|
|
256
|
+
...(job.workflow !== INVESTIGATION_WORKFLOW && job.diffSummary ? { diffSummary: job.diffSummary } : {}),
|
|
257
|
+
...(job.logTail ? { logTail: job.logTail } : {}),
|
|
258
|
+
...(job.workflow ? { workflow: job.workflow } : {}),
|
|
259
|
+
gates: job.run?.gates ?? [],
|
|
260
|
+
});
|
|
261
|
+
return {
|
|
262
|
+
...(rounds.length > 0 ? { rounds } : {}),
|
|
263
|
+
...(files.length > 0 ? { files } : {}),
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
function progressFor(job) {
|
|
267
|
+
const plan = job.run?.stepPlan?.length ? job.run.stepPlan : (job.stepPlan ?? []);
|
|
268
|
+
return stepProgress(plan, job.run?.stepEvents ?? []);
|
|
269
|
+
}
|
|
170
270
|
function toGateView(gate) {
|
|
171
271
|
const choices = gate.choices?.length ? gate.choices : ["approve", "reject"];
|
|
172
272
|
return {
|
|
@@ -217,7 +317,7 @@ function blockReasons(job, observation) {
|
|
|
217
317
|
}
|
|
218
318
|
return reasons;
|
|
219
319
|
}
|
|
220
|
-
export function renderTick(observation, actions, launches, delivers = [], findings = []) {
|
|
320
|
+
export function renderTick(observation, actions, launches, delivers = [], findings = [], notes = []) {
|
|
221
321
|
const lines = [`tick inFlight=${inFlightCount(observation)} maxConcurrent=${observation.maxConcurrent}`];
|
|
222
322
|
for (const job of observation.jobs) {
|
|
223
323
|
if (job.error)
|
|
@@ -237,14 +337,18 @@ export function renderTick(observation, actions, launches, delivers = [], findin
|
|
|
237
337
|
? `findings ${recorded.jobId} ${recorded.path}`
|
|
238
338
|
: `findings ${recorded.jobId} missing`);
|
|
239
339
|
}
|
|
340
|
+
for (const note of notes) {
|
|
341
|
+
const next = note.nextRetryAt ? ` next ${note.nextRetryAt}` : " dead";
|
|
342
|
+
lines.push(`${note.kind} ${note.jobId} ${note.reason} attempt ${note.attempt}${next}`);
|
|
343
|
+
}
|
|
240
344
|
for (const launch of launches) {
|
|
241
|
-
lines.push(
|
|
345
|
+
lines.push(`${launch.retry ? "retry" : "launch"} ${launch.jobId} ${launch.repo}`);
|
|
242
346
|
for (const line of launch.output.split("\n")) {
|
|
243
347
|
if (line.trim())
|
|
244
348
|
lines.push(` ${line}`);
|
|
245
349
|
}
|
|
246
350
|
}
|
|
247
|
-
if (!actions.some((action) => action.type === "launch") &&
|
|
351
|
+
if (!actions.some((action) => action.type === "launch" || action.type === "retry") &&
|
|
248
352
|
launches.length === 0 &&
|
|
249
353
|
delivers.length === 0 &&
|
|
250
354
|
findings.length === 0) {
|
package/dist/schemas.js
CHANGED
|
@@ -26,9 +26,20 @@ export const JiraConfigSchema = z.object({
|
|
|
26
26
|
email: z.string().optional(),
|
|
27
27
|
apiToken: z.string().optional(),
|
|
28
28
|
});
|
|
29
|
+
/** No yak event and no log output for this long stalls a running job. `<= 0` disables it. */
|
|
30
|
+
export const DEFAULT_STALL_TIMEOUT_MS = 15 * 60 * 1000;
|
|
31
|
+
/** Transient failures retry until this many attempts, then the job is dead. */
|
|
32
|
+
export const DEFAULT_MAX_ATTEMPTS = 3;
|
|
29
33
|
export const KilnConfigFileSchema = z.object({
|
|
30
34
|
note: z.string().min(1).optional(),
|
|
31
35
|
maxConcurrent: z.number().int().min(1).optional(),
|
|
36
|
+
/**
|
|
37
|
+
* Milliseconds without a yak event or log output before a running job is
|
|
38
|
+
* killed and retried. Default 15 minutes. `0` or negative disables stall detection.
|
|
39
|
+
*/
|
|
40
|
+
stallTimeoutMs: z.number().int().optional(),
|
|
41
|
+
/** Transient failures before the job is dead. Default 3. */
|
|
42
|
+
maxAttempts: z.number().int().min(1).optional(),
|
|
32
43
|
adapter: z.enum(["mock", "claude-code"]).optional(),
|
|
33
44
|
repos: z.record(z.string(), RepoEntrySchema),
|
|
34
45
|
jira: JiraConfigSchema.optional(),
|
|
@@ -79,7 +90,9 @@ export const JobSchema = z.object({
|
|
|
79
90
|
* `deliver=pr`. It does not enter `in-review`. A reject, a failure, a dead
|
|
80
91
|
* process, and an ok run with no readable approval do not unblock an
|
|
81
92
|
* after-merged dependent. A stacked job unblocks when its one upstream has
|
|
82
|
-
* landed, which includes `in-review`.
|
|
93
|
+
* landed, which includes `in-review`. `dead` is also a job whose transient
|
|
94
|
+
* failures have used `maxAttempts`. Those attempts live under the kiln
|
|
95
|
+
* directory, not in this file.
|
|
83
96
|
*/
|
|
84
97
|
export const JOB_STATES = [
|
|
85
98
|
"queued",
|