@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.
@@ -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
- ...(state === "dead" && job.run
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(`launch ${launch.jobId} ${launch.repo}`);
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",