@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
package/dist/driver.js
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
import { spawn, spawnSync } from "node:child_process";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
-
import { closeSync, existsSync, linkSync, mkdirSync, openSync, readdirSync, readFileSync, rmSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { closeSync, existsSync, linkSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
4
4
|
import { basename, dirname, isAbsolute, join } from "node:path";
|
|
5
|
+
import { buildAttention } from "./attention.js";
|
|
5
6
|
import { clearLaunchError, deliverJob, readDelivery, readLaunchError, resolveLaunchBase, writeDelivery, writeLaunchError, isMerged, } from "./delivery.js";
|
|
6
7
|
import { appendInvestigationFindings, existingFindingsPath, findingsFilePath, INVESTIGATION_WORKFLOW, readFindingsMarkdown, writeFindings, } from "./findings.js";
|
|
7
8
|
import { defaultHost } from "./host.js";
|
|
8
9
|
import { readJobSpec, resolveJobSpec } from "./jobs.js";
|
|
9
10
|
import { jobBasename, packageRoot } from "./paths.js";
|
|
10
11
|
import { decide, isLanded, jobState, } from "./reconcile.js";
|
|
12
|
+
import { fileMtimeMs, isPidAlive, readAttemptLedger, recoverRuns, resetAttemptLedger, } from "./reliability.js";
|
|
11
13
|
import { gateAnswerCommand, renderStatus, renderTick, statusView, yakCliPath, } from "./report.js";
|
|
12
14
|
import { JobSchema } from "./schemas.js";
|
|
13
|
-
import { materializeWorkflow, workflowInputs, workflowNeedsTestCommand } from "./workflow.js";
|
|
15
|
+
import { leafStepIds, materializeWorkflow, workflowInputs, workflowNeedsTestCommand, workflowStepPlan } from "./workflow.js";
|
|
14
16
|
const TAG_PREFIX = "kiln:";
|
|
15
17
|
export function jobTag(jobId) {
|
|
16
18
|
return `${TAG_PREFIX}${jobId}`;
|
|
@@ -25,16 +27,26 @@ export function parseJobTag(tag) {
|
|
|
25
27
|
}
|
|
26
28
|
/** The derived view `kiln status` prints and `kiln serve` renders. */
|
|
27
29
|
export function readStatus(config, jobId, host = defaultHost()) {
|
|
28
|
-
|
|
30
|
+
const view = statusView(observe(config, host).observation, jobId);
|
|
31
|
+
return { ...view, attention: buildAttention(view.jobs) };
|
|
29
32
|
}
|
|
30
33
|
export function runStatus(config, jobId, host = defaultHost()) {
|
|
31
34
|
return renderStatus(readStatus(config, jobId, host));
|
|
32
35
|
}
|
|
33
|
-
export function runTick(config, host = defaultHost()) {
|
|
36
|
+
export function runTick(config, host = defaultHost(), options = {}) {
|
|
37
|
+
const startup = options.startup ?? true;
|
|
38
|
+
const now = options.now ?? new Date();
|
|
34
39
|
return withTickLock(config.kilnDir, () => {
|
|
40
|
+
const notes = recoverRuns(config, observe(config, host).observation.jobs, { startup, now });
|
|
35
41
|
const world = observe(config, host);
|
|
36
|
-
const actions = decide(world.observation);
|
|
37
|
-
return apply(config, world, actions, host);
|
|
42
|
+
const actions = decide(world.observation, now);
|
|
43
|
+
return apply(config, world, actions, host, notes);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
/** Startup pass shared by `kiln tick` (via `runTick`) and `kiln serve`. Idempotent. */
|
|
47
|
+
export function startupRecovery(config, host = defaultHost(), now = new Date()) {
|
|
48
|
+
withTickLock(config.kilnDir, () => {
|
|
49
|
+
recoverRuns(config, observe(config, host).observation.jobs, { startup: true, now });
|
|
38
50
|
});
|
|
39
51
|
}
|
|
40
52
|
export function answerJob(config, jobId, choice, host = defaultHost()) {
|
|
@@ -65,6 +77,74 @@ export function answerJob(config, jobId, choice, host = defaultHost()) {
|
|
|
65
77
|
}
|
|
66
78
|
return output.endsWith("\n") || output.length === 0 ? output : `${output}\n`;
|
|
67
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* Run a failed or dead job again. The board Retry button and `kiln retry`
|
|
82
|
+
* both call this.
|
|
83
|
+
* A landed job whose only failure is opening the pull request is handed to
|
|
84
|
+
* the same deliver path tick uses. Everything else is a new `yak run`, the
|
|
85
|
+
* same launch tick uses. The attempt ledger is cleared either way, and the
|
|
86
|
+
* current run id is ignored so a dead job is not stuck on the old journal.
|
|
87
|
+
* A dead journal is cancelled first.
|
|
88
|
+
*/
|
|
89
|
+
export function retryJob(config, jobId, host = defaultHost()) {
|
|
90
|
+
return withTickLock(config.kilnDir, () => {
|
|
91
|
+
const world = observe(config, host);
|
|
92
|
+
const observed = world.observation.jobs.find((candidate) => candidate.jobId === jobId);
|
|
93
|
+
if (!observed)
|
|
94
|
+
throw new Error(`No job ${jobId}.`);
|
|
95
|
+
if (observed.error)
|
|
96
|
+
throw new Error(`Job ${jobId} is invalid: ${observed.error}`);
|
|
97
|
+
const state = jobState(observed, world.observation);
|
|
98
|
+
if (state !== "failed" && state !== "dead") {
|
|
99
|
+
throw new Error(`Job ${jobId} is ${state}, not failed or dead.`);
|
|
100
|
+
}
|
|
101
|
+
const job = world.jobs.get(jobId);
|
|
102
|
+
if (!job)
|
|
103
|
+
throw new Error(`No job ${jobId}.`);
|
|
104
|
+
const crumb = readBreadcrumbs(config.kilnDir).find((candidate) => candidate.jobId === jobId);
|
|
105
|
+
if (crumb && pidAlive(crumb.pid)) {
|
|
106
|
+
throw new Error(`Job ${jobId} is already launching.`);
|
|
107
|
+
}
|
|
108
|
+
const cleared = observed.attemptCount ?? 0;
|
|
109
|
+
resetAttemptLedger(config.kilnDir, jobId, observed.run?.runId);
|
|
110
|
+
rmSync(breadcrumbPath(config.kilnDir, jobId), { force: true });
|
|
111
|
+
clearLaunchError(config.kilnDir, jobId);
|
|
112
|
+
const noun = cleared === 1 ? "attempt" : "attempts";
|
|
113
|
+
const clearedLine = `cleared ${cleared} ${noun}\n`;
|
|
114
|
+
if (shouldRedeliver(observed)) {
|
|
115
|
+
const report = deliverObserved(config, world, jobId, host);
|
|
116
|
+
if (!report.ok)
|
|
117
|
+
throw new Error(report.detail);
|
|
118
|
+
const detail = report.detail.endsWith("\n") || report.detail.length === 0 ? report.detail : `${report.detail}\n`;
|
|
119
|
+
return `retry ${jobId} ${job.repo}\n${clearedLine}${detail}`;
|
|
120
|
+
}
|
|
121
|
+
if (state === "dead" && observed.run) {
|
|
122
|
+
try {
|
|
123
|
+
spawnSync(process.execPath, [yakCliPath(), "cancel", observed.run.runId], {
|
|
124
|
+
cwd: observed.run.checkout,
|
|
125
|
+
encoding: "utf8",
|
|
126
|
+
timeout: 20_000,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
// Cancel is the same cleanup status prints. The new run still supersedes the journal.
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const logPath = join(config.kilnDir, "logs", `${jobBasename(job.id)}.log`);
|
|
134
|
+
if (existsSync(logPath))
|
|
135
|
+
writeFileSync(logPath, "");
|
|
136
|
+
const report = launchJob(config, world, job, host);
|
|
137
|
+
const output = report.output.endsWith("\n") || report.output.length === 0 ? report.output : `${report.output}\n`;
|
|
138
|
+
return `retry ${report.jobId} ${report.repo}\n${clearedLine}${output}`;
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
function shouldRedeliver(job) {
|
|
142
|
+
if (!isLanded(job))
|
|
143
|
+
return false;
|
|
144
|
+
if (job.delivery?.prState === "CLOSED")
|
|
145
|
+
return false;
|
|
146
|
+
return Boolean(job.delivery?.error) && !job.delivery?.prNumber;
|
|
147
|
+
}
|
|
68
148
|
function gateForChoice(gates, choice) {
|
|
69
149
|
return (gates.find((gate) => gate.choices?.includes(choice)) ??
|
|
70
150
|
gates.find((gate) => !gate.choices?.length));
|
|
@@ -86,11 +166,16 @@ function observe(config, host) {
|
|
|
86
166
|
if (entry.job)
|
|
87
167
|
records.set(entry.job.id, entry.job);
|
|
88
168
|
const tag = jobTag(entry.meta.jobId);
|
|
89
|
-
const
|
|
169
|
+
const ledgerResult = readAttemptLedger(config.kilnDir, entry.meta.jobId);
|
|
170
|
+
const ledger = ledgerResult && ledgerResult !== "corrupt" ? ledgerResult : undefined;
|
|
171
|
+
const picked = selectRun(runs.get(tag) ?? [], ledger);
|
|
172
|
+
const run = picked.run;
|
|
90
173
|
const crumb = breadcrumbs.find((candidate) => candidate.jobId === entry.meta.jobId);
|
|
91
|
-
if (crumb && run)
|
|
174
|
+
if (crumb && run && !picked.retired)
|
|
92
175
|
breadcrumbsToClear.push({ path: crumb.path });
|
|
93
176
|
const childAlive = crumb ? pidAlive(crumb.pid) : false;
|
|
177
|
+
const launching = Boolean(crumb) && childAlive && (!run || picked.retired);
|
|
178
|
+
const launchFailed = Boolean(crumb) && !childAlive && (!run || picked.retired);
|
|
94
179
|
const repoKnown = entry.job ? Boolean(config.repos[entry.job.repo]) : false;
|
|
95
180
|
const repoError = entry.job && !repoKnown ? `Repo ${entry.job.repo} is not registered.` : undefined;
|
|
96
181
|
const repo = entry.job ? config.repos[entry.job.repo] : undefined;
|
|
@@ -103,10 +188,26 @@ function observe(config, host) {
|
|
|
103
188
|
? "stack needs exactly one dependency."
|
|
104
189
|
: undefined;
|
|
105
190
|
const logPath = crumb?.logPath || existingLog(config.kilnDir, entry.meta.jobId);
|
|
191
|
+
if (run && logPath)
|
|
192
|
+
run.activityMs = later(run.activityMs, fileMtimeMs(logPath));
|
|
193
|
+
const launchActivityMs = later(crumb ? fileMtimeMs(crumb.path) : undefined, logPath ? fileMtimeMs(logPath) : undefined);
|
|
194
|
+
const attemptCount = ledger?.attempts.length ?? 0;
|
|
195
|
+
const lastFailure = ledger?.attempts.at(-1);
|
|
196
|
+
const exhausted = attemptCount >= config.maxAttempts &&
|
|
197
|
+
attemptCount > 0 &&
|
|
198
|
+
!launching &&
|
|
199
|
+
(picked.retired || !run || run.status === "dead" || run.status === "failed");
|
|
106
200
|
const specPath = entry.job ? resolveJobSpec(config.kilnDir, entry.job.spec) : undefined;
|
|
107
201
|
const findingsPath = entry.job?.workflow === INVESTIGATION_WORKFLOW
|
|
108
202
|
? existingFindingsPath(config.kilnDir, entry.job.id)
|
|
109
203
|
: undefined;
|
|
204
|
+
const logTail = readLogTail(logPath);
|
|
205
|
+
const stepPlan = entry.job ? workflowStepPlan(entry.job.workflow) : undefined;
|
|
206
|
+
const diffSummary = run &&
|
|
207
|
+
run.status === "suspended" &&
|
|
208
|
+
entry.job?.workflow !== INVESTIGATION_WORKFLOW
|
|
209
|
+
? readDiffSummary(run.checkout, run.runId)
|
|
210
|
+
: undefined;
|
|
110
211
|
jobs.push({
|
|
111
212
|
jobId: entry.meta.jobId,
|
|
112
213
|
repo: entry.job?.repo ?? "",
|
|
@@ -119,11 +220,23 @@ function observe(config, host) {
|
|
|
119
220
|
...(entry.job?.stack ? { stack: true } : {}),
|
|
120
221
|
...(delivery ? { delivery } : {}),
|
|
121
222
|
...(findingsPath ? { findingsPath } : {}),
|
|
122
|
-
launching
|
|
123
|
-
launchFailed
|
|
223
|
+
launching,
|
|
224
|
+
launchFailed,
|
|
124
225
|
...(launchError ? { launchError } : {}),
|
|
226
|
+
...(crumb ? { launchPid: crumb.pid } : {}),
|
|
227
|
+
...(launchActivityMs !== undefined ? { launchActivityMs } : {}),
|
|
125
228
|
...(logPath ? { logPath } : {}),
|
|
229
|
+
...(entry.job?.createdAt ? { createdAt: entry.job.createdAt } : {}),
|
|
230
|
+
...(logTail ? { logTail } : {}),
|
|
231
|
+
...(diffSummary ? { diffSummary } : {}),
|
|
232
|
+
...(stepPlan && stepPlan.length > 0 ? { stepPlan } : {}),
|
|
126
233
|
...(run ? { run } : {}),
|
|
234
|
+
attemptCount,
|
|
235
|
+
...(lastFailure ? { lastFailureReason: lastFailure.reason } : {}),
|
|
236
|
+
...(ledger?.nextRetryAt && !exhausted ? { nextRetryAt: ledger.nextRetryAt } : {}),
|
|
237
|
+
...(exhausted ? { retriesExhausted: true } : {}),
|
|
238
|
+
...(picked.retired ? { runRetired: true } : {}),
|
|
239
|
+
...(ledgerResult === "corrupt" ? { ledgerError: "attempt ledger is unreadable" } : {}),
|
|
127
240
|
...(entry.meta.error || repoError || stackError
|
|
128
241
|
? { error: entry.meta.error ?? repoError ?? stackError }
|
|
129
242
|
: {}),
|
|
@@ -132,13 +245,14 @@ function observe(config, host) {
|
|
|
132
245
|
return {
|
|
133
246
|
observation: {
|
|
134
247
|
maxConcurrent: config.maxConcurrent,
|
|
248
|
+
maxAttempts: config.maxAttempts,
|
|
135
249
|
jobs,
|
|
136
250
|
breadcrumbsToClear,
|
|
137
251
|
},
|
|
138
252
|
jobs: records,
|
|
139
253
|
};
|
|
140
254
|
}
|
|
141
|
-
function apply(config, world, actions, host) {
|
|
255
|
+
function apply(config, world, actions, host, notes = []) {
|
|
142
256
|
const launches = [];
|
|
143
257
|
const delivers = [];
|
|
144
258
|
const findings = [];
|
|
@@ -161,9 +275,10 @@ function apply(config, world, actions, host) {
|
|
|
161
275
|
if (!job) {
|
|
162
276
|
throw new Error(`Tick planned a launch for ${action.jobId}, which is not in the observed jobs.`);
|
|
163
277
|
}
|
|
164
|
-
|
|
278
|
+
const report = launchJob(config, world, job, host);
|
|
279
|
+
launches.push(action.type === "retry" ? { ...report, retry: true } : report);
|
|
165
280
|
}
|
|
166
|
-
return renderTick(world.observation, actions, launches, delivers, findings);
|
|
281
|
+
return renderTick(world.observation, actions, launches, delivers, findings, notes);
|
|
167
282
|
}
|
|
168
283
|
/**
|
|
169
284
|
* Copy an approved investigation's markdown into `<kilnDir>/findings`.
|
|
@@ -429,6 +544,12 @@ function scanRuns(config) {
|
|
|
429
544
|
const status = classify(events);
|
|
430
545
|
const gateDecision = readApproveDecision(runDir);
|
|
431
546
|
const title = readRunTitle(runDir);
|
|
547
|
+
const finished = [...events].reverse().find((event) => event.t === "run.finished");
|
|
548
|
+
const stepPlan = readRunStepPlan(runDir);
|
|
549
|
+
const stepEvents = stepEventsFrom(events);
|
|
550
|
+
const pid = livePid(events);
|
|
551
|
+
const activityMs = eventActivityMs(events) ?? fileMtimeMs(join(runDir, "journal.jsonl"));
|
|
552
|
+
const finishedReason = finished && typeof finished.reason === "string" ? finished.reason : undefined;
|
|
432
553
|
const run = {
|
|
433
554
|
runId,
|
|
434
555
|
checkout: repo.path,
|
|
@@ -436,14 +557,175 @@ function scanRuns(config) {
|
|
|
436
557
|
gates: status === "suspended" ? openGates(runDir, events) : [],
|
|
437
558
|
...(gateDecision ? { gateDecision } : {}),
|
|
438
559
|
...(title ? { title } : {}),
|
|
560
|
+
...(typeof started?.at === "string" ? { startedAt: started.at } : {}),
|
|
561
|
+
...(typeof finished?.at === "string" ? { finishedAt: finished.at } : {}),
|
|
562
|
+
...(stepPlan.length > 0 ? { stepPlan } : {}),
|
|
563
|
+
...(stepEvents.length > 0 ? { stepEvents } : {}),
|
|
564
|
+
...(pid !== undefined ? { pid } : {}),
|
|
565
|
+
...(activityMs !== undefined ? { activityMs } : {}),
|
|
566
|
+
...(finishedReason ? { finishedReason } : {}),
|
|
439
567
|
};
|
|
440
|
-
const
|
|
441
|
-
|
|
442
|
-
|
|
568
|
+
const list = byTag.get(started.tag) ?? [];
|
|
569
|
+
list.push(run);
|
|
570
|
+
byTag.set(started.tag, list);
|
|
443
571
|
}
|
|
444
572
|
}
|
|
445
573
|
return byTag;
|
|
446
574
|
}
|
|
575
|
+
function stepEventsFrom(events) {
|
|
576
|
+
const found = [];
|
|
577
|
+
for (const event of events) {
|
|
578
|
+
if (event.t !== "step.started" && event.t !== "step.completed" && event.t !== "step.failed")
|
|
579
|
+
continue;
|
|
580
|
+
if (!event.stepId)
|
|
581
|
+
continue;
|
|
582
|
+
const detail = event.t === "step.failed" ? formatFailure(event.failure) : undefined;
|
|
583
|
+
found.push({
|
|
584
|
+
t: event.t,
|
|
585
|
+
stepId: event.stepId,
|
|
586
|
+
...(detail ? { detail } : {}),
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
return found;
|
|
590
|
+
}
|
|
591
|
+
function formatFailure(failure) {
|
|
592
|
+
if (!failure)
|
|
593
|
+
return undefined;
|
|
594
|
+
const detail = typeof failure.detail === "string" ? failure.detail.trim() : "";
|
|
595
|
+
const reason = typeof failure.reason === "string" ? failure.reason.trim() : "";
|
|
596
|
+
const text = detail && reason ? `${detail} (${reason})` : detail || reason;
|
|
597
|
+
return text || undefined;
|
|
598
|
+
}
|
|
599
|
+
function readRunStepPlan(runDir) {
|
|
600
|
+
const file = join(runDir, "workflow.json");
|
|
601
|
+
if (!existsSync(file))
|
|
602
|
+
return [];
|
|
603
|
+
try {
|
|
604
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
605
|
+
return leafStepIds(parsed.steps);
|
|
606
|
+
}
|
|
607
|
+
catch {
|
|
608
|
+
return [];
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
function readDiffSummary(checkout, runId) {
|
|
612
|
+
const worktree = join(checkout, ".yak", "worktrees", runId);
|
|
613
|
+
if (existsSync(worktree)) {
|
|
614
|
+
const result = spawnSync("git", ["diff", "--stat", "HEAD"], {
|
|
615
|
+
cwd: worktree,
|
|
616
|
+
encoding: "utf8",
|
|
617
|
+
timeout: 5_000,
|
|
618
|
+
});
|
|
619
|
+
const text = result.status === 0 ? result.stdout.trim() : "";
|
|
620
|
+
if (text)
|
|
621
|
+
return clipText(text, 1500);
|
|
622
|
+
}
|
|
623
|
+
return readBuildSummary(join(checkout, ".runs", runId));
|
|
624
|
+
}
|
|
625
|
+
function readBuildSummary(runDir) {
|
|
626
|
+
const dir = join(runDir, "artifacts");
|
|
627
|
+
if (!existsSync(dir))
|
|
628
|
+
return undefined;
|
|
629
|
+
let files;
|
|
630
|
+
try {
|
|
631
|
+
files = readdirSync(dir);
|
|
632
|
+
}
|
|
633
|
+
catch {
|
|
634
|
+
return undefined;
|
|
635
|
+
}
|
|
636
|
+
let best;
|
|
637
|
+
for (const file of files) {
|
|
638
|
+
const match = /^build(?:\.(\d+))?\.json$/.exec(file);
|
|
639
|
+
if (!match)
|
|
640
|
+
continue;
|
|
641
|
+
const iter = match[1] ? Number(match[1]) : 0;
|
|
642
|
+
if (!best || iter >= best.iter)
|
|
643
|
+
best = { iter, file };
|
|
644
|
+
}
|
|
645
|
+
if (!best)
|
|
646
|
+
return undefined;
|
|
647
|
+
try {
|
|
648
|
+
const parsed = JSON.parse(readFileSync(join(dir, best.file), "utf8"));
|
|
649
|
+
if (typeof parsed.summary !== "string" || !parsed.summary.trim())
|
|
650
|
+
return undefined;
|
|
651
|
+
return clipText(parsed.summary.trim(), 280);
|
|
652
|
+
}
|
|
653
|
+
catch {
|
|
654
|
+
return undefined;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
function readLogTail(logPath) {
|
|
658
|
+
if (!logPath)
|
|
659
|
+
return undefined;
|
|
660
|
+
let size;
|
|
661
|
+
try {
|
|
662
|
+
size = statSync(logPath).size;
|
|
663
|
+
}
|
|
664
|
+
catch {
|
|
665
|
+
return undefined;
|
|
666
|
+
}
|
|
667
|
+
if (size <= 0)
|
|
668
|
+
return undefined;
|
|
669
|
+
const take = Math.min(size, 64_000);
|
|
670
|
+
let fd;
|
|
671
|
+
try {
|
|
672
|
+
fd = openSync(logPath, "r");
|
|
673
|
+
const buffer = Buffer.alloc(take);
|
|
674
|
+
readSync(fd, buffer, 0, take, size - take);
|
|
675
|
+
const text = buffer.toString("utf8");
|
|
676
|
+
const lines = text.split(/\r?\n/);
|
|
677
|
+
if (size > take)
|
|
678
|
+
lines.shift();
|
|
679
|
+
while (lines.length > 0 && lines[lines.length - 1] === "")
|
|
680
|
+
lines.pop();
|
|
681
|
+
const tail = lines.slice(-40).join("\n").trim();
|
|
682
|
+
return tail || undefined;
|
|
683
|
+
}
|
|
684
|
+
catch {
|
|
685
|
+
return undefined;
|
|
686
|
+
}
|
|
687
|
+
finally {
|
|
688
|
+
if (fd !== undefined)
|
|
689
|
+
closeSync(fd);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
/**
|
|
693
|
+
* Prefer a run that is not already a recorded failure, so a retry is visible
|
|
694
|
+
* even when its id does not sort after the failed one. Otherwise keep the
|
|
695
|
+
* latest id, which is what a single-run job has always shown.
|
|
696
|
+
*/
|
|
697
|
+
function selectRun(runs, ledger) {
|
|
698
|
+
const ignored = new Set(ledger?.ignoredRunIds ?? []);
|
|
699
|
+
const failed = new Set((ledger?.attempts ?? []).flatMap((attempt) => (attempt.runId ? [attempt.runId] : [])));
|
|
700
|
+
const visible = runs.filter((run) => !ignored.has(run.runId));
|
|
701
|
+
visible.sort((left, right) => (left.runId < right.runId ? 1 : left.runId > right.runId ? -1 : 0));
|
|
702
|
+
const fresh = visible.filter((run) => !failed.has(run.runId));
|
|
703
|
+
if (fresh[0])
|
|
704
|
+
return { run: fresh[0], retired: false };
|
|
705
|
+
if (visible[0])
|
|
706
|
+
return { run: visible[0], retired: true };
|
|
707
|
+
return { retired: false };
|
|
708
|
+
}
|
|
709
|
+
function later(left, right) {
|
|
710
|
+
if (left === undefined)
|
|
711
|
+
return right;
|
|
712
|
+
if (right === undefined)
|
|
713
|
+
return left;
|
|
714
|
+
return Math.max(left, right);
|
|
715
|
+
}
|
|
716
|
+
function eventActivityMs(events) {
|
|
717
|
+
let latest;
|
|
718
|
+
for (const event of events) {
|
|
719
|
+
if (!event.at)
|
|
720
|
+
continue;
|
|
721
|
+
const parsed = Date.parse(event.at);
|
|
722
|
+
if (!Number.isFinite(parsed))
|
|
723
|
+
continue;
|
|
724
|
+
if (latest === undefined || parsed > latest)
|
|
725
|
+
latest = parsed;
|
|
726
|
+
}
|
|
727
|
+
return latest;
|
|
728
|
+
}
|
|
447
729
|
function listRunDirs(checkout) {
|
|
448
730
|
const runsDir = join(checkout, ".runs");
|
|
449
731
|
if (!existsSync(runsDir))
|
|
@@ -807,15 +1089,7 @@ function releaseLock(lockPath) {
|
|
|
807
1089
|
}
|
|
808
1090
|
}
|
|
809
1091
|
function pidAlive(pid) {
|
|
810
|
-
|
|
811
|
-
return false;
|
|
812
|
-
try {
|
|
813
|
-
process.kill(pid, 0);
|
|
814
|
-
return true;
|
|
815
|
-
}
|
|
816
|
-
catch (error) {
|
|
817
|
-
return isErrno(error, "EPERM");
|
|
818
|
-
}
|
|
1092
|
+
return isPidAlive(pid);
|
|
819
1093
|
}
|
|
820
1094
|
function isErrno(error, code) {
|
|
821
1095
|
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
package/dist/reconcile.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { INVESTIGATION_WORKFLOW } from "./findings.js";
|
|
2
|
+
/** Why a transient attempt failed. Written under `<kilnDir>/attempts`, not on the job. */
|
|
3
|
+
export const FAILURE_REASONS = ["stalled", "orphaned", "crashed", "failed"];
|
|
2
4
|
export function inFlightCount(observation) {
|
|
3
5
|
return observation.jobs.filter((job) => inFlight(job, observation)).length;
|
|
4
6
|
}
|
|
@@ -18,33 +20,48 @@ function inFlight(job, observation) {
|
|
|
18
20
|
* An investigation is `done` on approve and never `in-review`.
|
|
19
21
|
*/
|
|
20
22
|
export function jobState(job, observation) {
|
|
21
|
-
if (job.
|
|
22
|
-
return "failed";
|
|
23
|
-
if (job.run) {
|
|
24
|
-
if (isLanded(job))
|
|
25
|
-
return landedState(job);
|
|
26
|
-
if (job.run.status === "ok" && job.run.gateDecision === "reject")
|
|
27
|
-
return "rejected";
|
|
28
|
-
if (job.run.status === "ok")
|
|
29
|
-
return "failed";
|
|
30
|
-
if (job.run.status === "failed")
|
|
31
|
-
return "failed";
|
|
32
|
-
if (job.run.status === "dead")
|
|
33
|
-
return "dead";
|
|
34
|
-
if (job.run.status === "suspended")
|
|
35
|
-
return "needs-human";
|
|
36
|
-
if (job.run.gateDecision === "approve")
|
|
37
|
-
return "approved";
|
|
23
|
+
if (job.launching && (!job.run || job.runRetired))
|
|
38
24
|
return "running";
|
|
39
|
-
|
|
25
|
+
if (job.run && !job.runRetired)
|
|
26
|
+
return activeRunState(job);
|
|
27
|
+
if (job.run && isLanded(job))
|
|
28
|
+
return landedState(job);
|
|
29
|
+
if (job.run?.status === "ok" && job.run.gateDecision === "reject")
|
|
30
|
+
return "rejected";
|
|
31
|
+
if (job.run?.status === "suspended")
|
|
32
|
+
return "needs-human";
|
|
33
|
+
if (job.retriesExhausted)
|
|
34
|
+
return "dead";
|
|
35
|
+
if (job.runRetired || job.run?.status === "failed" || job.run?.status === "dead")
|
|
36
|
+
return "failed";
|
|
40
37
|
if (job.launching)
|
|
41
38
|
return "running";
|
|
42
39
|
if (!dependenciesMet(job, observation))
|
|
43
40
|
return "blocked";
|
|
44
|
-
if (job.launchError)
|
|
41
|
+
if (job.launchError || job.launchFailed)
|
|
45
42
|
return "failed";
|
|
46
43
|
return "queued";
|
|
47
44
|
}
|
|
45
|
+
/** State of the current run, before the attempt ledger retires it. */
|
|
46
|
+
function activeRunState(job) {
|
|
47
|
+
if (!job.run)
|
|
48
|
+
return "queued";
|
|
49
|
+
if (isLanded(job))
|
|
50
|
+
return landedState(job);
|
|
51
|
+
if (job.run.status === "ok" && job.run.gateDecision === "reject")
|
|
52
|
+
return "rejected";
|
|
53
|
+
if (job.run.status === "ok")
|
|
54
|
+
return "failed";
|
|
55
|
+
if (job.run.status === "failed")
|
|
56
|
+
return "failed";
|
|
57
|
+
if (job.run.status === "dead")
|
|
58
|
+
return "dead";
|
|
59
|
+
if (job.run.status === "suspended")
|
|
60
|
+
return "needs-human";
|
|
61
|
+
if (job.run.gateDecision === "approve")
|
|
62
|
+
return "approved";
|
|
63
|
+
return "running";
|
|
64
|
+
}
|
|
48
65
|
/**
|
|
49
66
|
* After-merged (the default) needs every upstream `done`.
|
|
50
67
|
* Stacked needs its one upstream landed (approved and the run finished ok),
|
|
@@ -113,7 +130,24 @@ function canLaunch(job, observation) {
|
|
|
113
130
|
return false;
|
|
114
131
|
return dependenciesMet(job, observation);
|
|
115
132
|
}
|
|
116
|
-
|
|
133
|
+
/** A recorded transient failure whose backoff has elapsed. One retry per tick, ahead of a new job. */
|
|
134
|
+
function canRetry(job, observation, now) {
|
|
135
|
+
if (job.error || job.launching || job.retriesExhausted || job.ledgerError)
|
|
136
|
+
return false;
|
|
137
|
+
if (job.run && !job.runRetired)
|
|
138
|
+
return false;
|
|
139
|
+
if (!job.nextRetryAt)
|
|
140
|
+
return false;
|
|
141
|
+
const due = Date.parse(job.nextRetryAt);
|
|
142
|
+
if (!Number.isFinite(due) || due > now.getTime())
|
|
143
|
+
return false;
|
|
144
|
+
if (job.run?.gateDecision === "reject" || isLanded(job))
|
|
145
|
+
return false;
|
|
146
|
+
if (job.run?.status === "suspended")
|
|
147
|
+
return false;
|
|
148
|
+
return dependenciesMet(job, observation);
|
|
149
|
+
}
|
|
150
|
+
export function decide(observation, now = new Date()) {
|
|
117
151
|
const actions = observation.breadcrumbsToClear.map((crumb) => ({
|
|
118
152
|
type: "clear-breadcrumb",
|
|
119
153
|
path: crumb.path,
|
|
@@ -132,6 +166,11 @@ export function decide(observation) {
|
|
|
132
166
|
}
|
|
133
167
|
if (inFlightCount(observation) >= observation.maxConcurrent)
|
|
134
168
|
return actions;
|
|
169
|
+
const retry = observation.jobs.find((job) => canRetry(job, observation, now));
|
|
170
|
+
if (retry) {
|
|
171
|
+
actions.push({ type: "retry", jobId: retry.jobId, repo: retry.repo });
|
|
172
|
+
return actions;
|
|
173
|
+
}
|
|
135
174
|
const ready = observation.jobs.filter((job) => canLaunch(job, observation));
|
|
136
175
|
const healthy = ready.filter((job) => !job.launchError);
|
|
137
176
|
const picked = healthy[0] ?? ready[0];
|