@lchase/kiln 0.3.0 → 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 +15 -9
- package/assets/serve.css +404 -185
- package/assets/serve.js +361 -0
- package/dist/attention.js +264 -0
- package/dist/board.js +326 -0
- package/dist/cli.js +34 -6
- package/dist/config.js +4 -1
- package/dist/driver.js +717 -67
- package/dist/feedback.js +610 -0
- package/dist/host.js +26 -1
- package/dist/reconcile.js +88 -21
- package/dist/reliability.js +441 -0
- package/dist/report.js +153 -10
- package/dist/schemas.js +21 -1
- package/dist/serve-page.js +301 -159
- package/dist/serve.js +36 -14
- package/dist/workflow.js +69 -0
- package/package.json +1 -1
- package/prompts/kiln/fix.md +17 -0
- package/workflows/fix.yaml +44 -0
package/dist/driver.js
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
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";
|
|
7
|
+
import { feedbackSpec, openFeedbackRound, planFeedback, readFeedbackLedger, writeFeedbackLedger, } from "./feedback.js";
|
|
6
8
|
import { appendInvestigationFindings, existingFindingsPath, findingsFilePath, INVESTIGATION_WORKFLOW, readFindingsMarkdown, writeFindings, } from "./findings.js";
|
|
7
9
|
import { defaultHost } from "./host.js";
|
|
8
10
|
import { readJobSpec, resolveJobSpec } from "./jobs.js";
|
|
9
11
|
import { jobBasename, packageRoot } from "./paths.js";
|
|
10
12
|
import { decide, isLanded, jobState, } from "./reconcile.js";
|
|
13
|
+
import { fileMtimeMs, isPidAlive, readAttemptLedger, recoverRuns, resetAttemptLedger, } from "./reliability.js";
|
|
11
14
|
import { gateAnswerCommand, renderStatus, renderTick, statusView, yakCliPath, } from "./report.js";
|
|
12
15
|
import { JobSchema } from "./schemas.js";
|
|
13
|
-
import { materializeWorkflow, workflowInputs, workflowNeedsTestCommand } from "./workflow.js";
|
|
16
|
+
import { leafStepIds, materializeWorkflow, workflowInputs, workflowNeedsTestCommand, workflowStepPlan } from "./workflow.js";
|
|
14
17
|
const TAG_PREFIX = "kiln:";
|
|
15
18
|
export function jobTag(jobId) {
|
|
16
19
|
return `${TAG_PREFIX}${jobId}`;
|
|
@@ -25,16 +28,30 @@ export function parseJobTag(tag) {
|
|
|
25
28
|
}
|
|
26
29
|
/** The derived view `kiln status` prints and `kiln serve` renders. */
|
|
27
30
|
export function readStatus(config, jobId, host = defaultHost()) {
|
|
28
|
-
|
|
31
|
+
const view = statusView(observe(config, host).observation, jobId);
|
|
32
|
+
return { ...view, attention: buildAttention(view.jobs) };
|
|
29
33
|
}
|
|
30
34
|
export function runStatus(config, jobId, host = defaultHost()) {
|
|
31
35
|
return renderStatus(readStatus(config, jobId, host));
|
|
32
36
|
}
|
|
33
|
-
export function runTick(config, host = defaultHost()) {
|
|
37
|
+
export function runTick(config, host = defaultHost(), options = {}) {
|
|
38
|
+
const startup = options.startup ?? true;
|
|
39
|
+
const now = options.now ?? new Date();
|
|
34
40
|
return withTickLock(config.kilnDir, () => {
|
|
41
|
+
const notes = recoverRuns(config, observe(config, host).observation.jobs, { startup, now });
|
|
42
|
+
const midway = observe(config, host);
|
|
43
|
+
bindOpenFixRuns(config, midway);
|
|
44
|
+
const published = publishReadyFixes(config, host, midway);
|
|
35
45
|
const world = observe(config, host);
|
|
36
|
-
const
|
|
37
|
-
|
|
46
|
+
const feedbackNotes = watchPullRequests(config, world, host);
|
|
47
|
+
const actions = decide(world.observation, now);
|
|
48
|
+
return apply(config, world, actions, host, notes, now, [...published, ...feedbackNotes]);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
/** Startup pass shared by `kiln tick` (via `runTick`) and `kiln serve`. Idempotent. */
|
|
52
|
+
export function startupRecovery(config, host = defaultHost(), now = new Date()) {
|
|
53
|
+
withTickLock(config.kilnDir, () => {
|
|
54
|
+
recoverRuns(config, observe(config, host).observation.jobs, { startup: true, now });
|
|
38
55
|
});
|
|
39
56
|
}
|
|
40
57
|
export function answerJob(config, jobId, choice, host = defaultHost()) {
|
|
@@ -65,6 +82,74 @@ export function answerJob(config, jobId, choice, host = defaultHost()) {
|
|
|
65
82
|
}
|
|
66
83
|
return output.endsWith("\n") || output.length === 0 ? output : `${output}\n`;
|
|
67
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* Run a failed or dead job again. The board Retry button and `kiln retry`
|
|
87
|
+
* both call this.
|
|
88
|
+
* A landed job whose only failure is opening the pull request is handed to
|
|
89
|
+
* the same deliver path tick uses. Everything else is a new `yak run`, the
|
|
90
|
+
* same launch tick uses. The attempt ledger is cleared either way, and the
|
|
91
|
+
* current run id is ignored so a dead job is not stuck on the old journal.
|
|
92
|
+
* A dead journal is cancelled first.
|
|
93
|
+
*/
|
|
94
|
+
export function retryJob(config, jobId, host = defaultHost()) {
|
|
95
|
+
return withTickLock(config.kilnDir, () => {
|
|
96
|
+
const world = observe(config, host);
|
|
97
|
+
const observed = world.observation.jobs.find((candidate) => candidate.jobId === jobId);
|
|
98
|
+
if (!observed)
|
|
99
|
+
throw new Error(`No job ${jobId}.`);
|
|
100
|
+
if (observed.error)
|
|
101
|
+
throw new Error(`Job ${jobId} is invalid: ${observed.error}`);
|
|
102
|
+
const state = jobState(observed, world.observation);
|
|
103
|
+
if (state !== "failed" && state !== "dead") {
|
|
104
|
+
throw new Error(`Job ${jobId} is ${state}, not failed or dead.`);
|
|
105
|
+
}
|
|
106
|
+
const job = world.jobs.get(jobId);
|
|
107
|
+
if (!job)
|
|
108
|
+
throw new Error(`No job ${jobId}.`);
|
|
109
|
+
const crumb = readBreadcrumbs(config.kilnDir).find((candidate) => candidate.jobId === jobId);
|
|
110
|
+
if (crumb && pidAlive(crumb.pid)) {
|
|
111
|
+
throw new Error(`Job ${jobId} is already launching.`);
|
|
112
|
+
}
|
|
113
|
+
const cleared = observed.attemptCount ?? 0;
|
|
114
|
+
resetAttemptLedger(config.kilnDir, jobId, observed.run?.runId);
|
|
115
|
+
rmSync(breadcrumbPath(config.kilnDir, jobId), { force: true });
|
|
116
|
+
clearLaunchError(config.kilnDir, jobId);
|
|
117
|
+
const noun = cleared === 1 ? "attempt" : "attempts";
|
|
118
|
+
const clearedLine = `cleared ${cleared} ${noun}\n`;
|
|
119
|
+
if (shouldRedeliver(observed)) {
|
|
120
|
+
const report = deliverObserved(config, world, jobId, host);
|
|
121
|
+
if (!report.ok)
|
|
122
|
+
throw new Error(report.detail);
|
|
123
|
+
const detail = report.detail.endsWith("\n") || report.detail.length === 0 ? report.detail : `${report.detail}\n`;
|
|
124
|
+
return `retry ${jobId} ${job.repo}\n${clearedLine}${detail}`;
|
|
125
|
+
}
|
|
126
|
+
if (state === "dead" && observed.run) {
|
|
127
|
+
try {
|
|
128
|
+
spawnSync(process.execPath, [yakCliPath(), "cancel", observed.run.runId], {
|
|
129
|
+
cwd: observed.run.checkout,
|
|
130
|
+
encoding: "utf8",
|
|
131
|
+
timeout: 20_000,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
// Cancel is the same cleanup status prints. The new run still supersedes the journal.
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const logPath = join(config.kilnDir, "logs", `${jobBasename(job.id)}.log`);
|
|
139
|
+
if (existsSync(logPath))
|
|
140
|
+
writeFileSync(logPath, "");
|
|
141
|
+
const report = launchJob(config, world, job, host);
|
|
142
|
+
const output = report.output.endsWith("\n") || report.output.length === 0 ? report.output : `${report.output}\n`;
|
|
143
|
+
return `retry ${report.jobId} ${report.repo}\n${clearedLine}${output}`;
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
function shouldRedeliver(job) {
|
|
147
|
+
if (!isLanded(job))
|
|
148
|
+
return false;
|
|
149
|
+
if (job.delivery?.prState === "CLOSED")
|
|
150
|
+
return false;
|
|
151
|
+
return Boolean(job.delivery?.error) && !job.delivery?.prNumber;
|
|
152
|
+
}
|
|
68
153
|
function gateForChoice(gates, choice) {
|
|
69
154
|
return (gates.find((gate) => gate.choices?.includes(choice)) ??
|
|
70
155
|
gates.find((gate) => !gate.choices?.length));
|
|
@@ -86,27 +171,53 @@ function observe(config, host) {
|
|
|
86
171
|
if (entry.job)
|
|
87
172
|
records.set(entry.job.id, entry.job);
|
|
88
173
|
const tag = jobTag(entry.meta.jobId);
|
|
89
|
-
const
|
|
174
|
+
const ledgerResult = readAttemptLedger(config.kilnDir, entry.meta.jobId);
|
|
175
|
+
const ledger = ledgerResult && ledgerResult !== "corrupt" ? ledgerResult : undefined;
|
|
176
|
+
const feedbackResult = readFeedbackLedger(config.kilnDir, entry.meta.jobId);
|
|
177
|
+
const feedback = feedbackResult && feedbackResult !== "corrupt" ? feedbackResult : undefined;
|
|
178
|
+
const repoKnown = entry.job ? Boolean(config.repos[entry.job.repo]) : false;
|
|
179
|
+
const repo = entry.job ? config.repos[entry.job.repo] : undefined;
|
|
180
|
+
const deliver = repo?.deliver ?? "local";
|
|
181
|
+
const deliveryRecord = entry.job && repo && deliver === "pr" ? readDelivery(config.kilnDir, entry.job.id) : undefined;
|
|
182
|
+
const picked = selectJobRun(runs.get(tag) ?? [], ledger, feedback, deliveryRecord?.runId);
|
|
183
|
+
const run = picked.run;
|
|
90
184
|
const crumb = breadcrumbs.find((candidate) => candidate.jobId === entry.meta.jobId);
|
|
91
|
-
if (crumb && run)
|
|
185
|
+
if (crumb && run && !picked.retired)
|
|
92
186
|
breadcrumbsToClear.push({ path: crumb.path });
|
|
93
187
|
const childAlive = crumb ? pidAlive(crumb.pid) : false;
|
|
94
|
-
const
|
|
188
|
+
const hideBase = picked.fixInProgress && !run;
|
|
189
|
+
const launching = Boolean(crumb) && childAlive && (hideBase || !run || picked.retired);
|
|
190
|
+
const launchFailed = Boolean(crumb) && !childAlive && (hideBase || !run || picked.retired);
|
|
95
191
|
const repoError = entry.job && !repoKnown ? `Repo ${entry.job.repo} is not registered.` : undefined;
|
|
96
|
-
const
|
|
97
|
-
const deliver = repo?.deliver ?? "local";
|
|
192
|
+
const deliveryRunId = picked.fixInProgress ? deliveryRecord?.runId : run?.runId;
|
|
98
193
|
const delivery = entry.job && repo && deliver === "pr"
|
|
99
|
-
? observedDelivery(config.kilnDir, host, repo.path, entry.job.id,
|
|
194
|
+
? observedDelivery(config.kilnDir, host, repo.path, entry.job.id, deliveryRunId)
|
|
100
195
|
: undefined;
|
|
101
196
|
const launchError = entry.job && !run ? readLaunchError(config.kilnDir, entry.job.id) : undefined;
|
|
102
197
|
const stackError = entry.job?.stack && (entry.job.dependsOn?.length ?? 0) !== 1
|
|
103
198
|
? "stack needs exactly one dependency."
|
|
104
199
|
: undefined;
|
|
105
200
|
const logPath = crumb?.logPath || existingLog(config.kilnDir, entry.meta.jobId);
|
|
201
|
+
if (run && logPath)
|
|
202
|
+
run.activityMs = later(run.activityMs, fileMtimeMs(logPath));
|
|
203
|
+
const launchActivityMs = later(crumb ? fileMtimeMs(crumb.path) : undefined, logPath ? fileMtimeMs(logPath) : undefined);
|
|
204
|
+
const attemptCount = ledger?.attempts.length ?? 0;
|
|
205
|
+
const lastFailure = ledger?.attempts.at(-1);
|
|
206
|
+
const exhausted = attemptCount >= config.maxAttempts &&
|
|
207
|
+
attemptCount > 0 &&
|
|
208
|
+
!launching &&
|
|
209
|
+
(picked.retired || !run || run.status === "dead" || run.status === "failed");
|
|
106
210
|
const specPath = entry.job ? resolveJobSpec(config.kilnDir, entry.job.spec) : undefined;
|
|
107
211
|
const findingsPath = entry.job?.workflow === INVESTIGATION_WORKFLOW
|
|
108
212
|
? existingFindingsPath(config.kilnDir, entry.job.id)
|
|
109
213
|
: undefined;
|
|
214
|
+
const logTail = readLogTail(logPath);
|
|
215
|
+
const stepPlan = entry.job ? workflowStepPlan(entry.job.workflow) : undefined;
|
|
216
|
+
const diffSummary = run &&
|
|
217
|
+
run.status === "suspended" &&
|
|
218
|
+
entry.job?.workflow !== INVESTIGATION_WORKFLOW
|
|
219
|
+
? readDiffSummary(run.checkout, run.runId)
|
|
220
|
+
: undefined;
|
|
110
221
|
jobs.push({
|
|
111
222
|
jobId: entry.meta.jobId,
|
|
112
223
|
repo: entry.job?.repo ?? "",
|
|
@@ -119,11 +230,29 @@ function observe(config, host) {
|
|
|
119
230
|
...(entry.job?.stack ? { stack: true } : {}),
|
|
120
231
|
...(delivery ? { delivery } : {}),
|
|
121
232
|
...(findingsPath ? { findingsPath } : {}),
|
|
122
|
-
launching
|
|
123
|
-
launchFailed
|
|
233
|
+
launching,
|
|
234
|
+
launchFailed,
|
|
124
235
|
...(launchError ? { launchError } : {}),
|
|
236
|
+
...(crumb ? { launchPid: crumb.pid } : {}),
|
|
237
|
+
...(launchActivityMs !== undefined ? { launchActivityMs } : {}),
|
|
125
238
|
...(logPath ? { logPath } : {}),
|
|
239
|
+
...(entry.job?.createdAt ? { createdAt: entry.job.createdAt } : {}),
|
|
240
|
+
...(logTail ? { logTail } : {}),
|
|
241
|
+
...(diffSummary ? { diffSummary } : {}),
|
|
242
|
+
...(stepPlan && stepPlan.length > 0 ? { stepPlan } : {}),
|
|
126
243
|
...(run ? { run } : {}),
|
|
244
|
+
attemptCount,
|
|
245
|
+
...(lastFailure ? { lastFailureReason: lastFailure.reason } : {}),
|
|
246
|
+
...(ledger?.nextRetryAt && !exhausted ? { nextRetryAt: ledger.nextRetryAt } : {}),
|
|
247
|
+
...(exhausted ? { retriesExhausted: true } : {}),
|
|
248
|
+
...(picked.retired ? { runRetired: true } : {}),
|
|
249
|
+
...(ledgerResult === "corrupt" ? { ledgerError: "attempt ledger is unreadable" } : {}),
|
|
250
|
+
...(feedback?.rounds.length ? { feedbackRounds: feedback.rounds } : {}),
|
|
251
|
+
...(feedback?.exhausted ? { feedbackExhausted: true } : {}),
|
|
252
|
+
...(feedback?.ambiguous ? { feedbackAmbiguous: true } : {}),
|
|
253
|
+
...(feedbackResult === "corrupt" ? { feedbackLedgerError: "feedback ledger is unreadable" } : {}),
|
|
254
|
+
...(picked.fixInProgress ? { fixInProgress: true } : {}),
|
|
255
|
+
...(picked.fixAwaitingPublish ? { fixAwaitingPublish: true } : {}),
|
|
127
256
|
...(entry.meta.error || repoError || stackError
|
|
128
257
|
? { error: entry.meta.error ?? repoError ?? stackError }
|
|
129
258
|
: {}),
|
|
@@ -132,13 +261,14 @@ function observe(config, host) {
|
|
|
132
261
|
return {
|
|
133
262
|
observation: {
|
|
134
263
|
maxConcurrent: config.maxConcurrent,
|
|
264
|
+
maxAttempts: config.maxAttempts,
|
|
135
265
|
jobs,
|
|
136
266
|
breadcrumbsToClear,
|
|
137
267
|
},
|
|
138
268
|
jobs: records,
|
|
139
269
|
};
|
|
140
270
|
}
|
|
141
|
-
function apply(config, world, actions, host) {
|
|
271
|
+
function apply(config, world, actions, host, notes = [], now = new Date(), feedbackNotes = []) {
|
|
142
272
|
const launches = [];
|
|
143
273
|
const delivers = [];
|
|
144
274
|
const findings = [];
|
|
@@ -157,13 +287,28 @@ function apply(config, world, actions, host) {
|
|
|
157
287
|
findings.push(recorded);
|
|
158
288
|
continue;
|
|
159
289
|
}
|
|
290
|
+
if (action.type === "mark-feedback") {
|
|
291
|
+
markFeedback(config.kilnDir, action.jobId, action.reason);
|
|
292
|
+
feedbackNotes.push(`${action.reason} ${action.jobId}`);
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
if (action.type === "start-fix") {
|
|
296
|
+
const job = world.jobs.get(action.jobId);
|
|
297
|
+
if (!job) {
|
|
298
|
+
throw new Error(`Tick planned a fix for ${action.jobId}, which is not in the observed jobs.`);
|
|
299
|
+
}
|
|
300
|
+
const report = startFix(config, world, job, host, now);
|
|
301
|
+
launches.push({ ...report, kind: "fix" });
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
160
304
|
const job = world.jobs.get(action.jobId);
|
|
161
305
|
if (!job) {
|
|
162
306
|
throw new Error(`Tick planned a launch for ${action.jobId}, which is not in the observed jobs.`);
|
|
163
307
|
}
|
|
164
|
-
|
|
308
|
+
const report = launchJob(config, world, job, host);
|
|
309
|
+
launches.push(action.type === "retry" ? { ...report, retry: true } : report);
|
|
165
310
|
}
|
|
166
|
-
return renderTick(world.observation, actions, launches, delivers, findings);
|
|
311
|
+
return renderTick(world.observation, actions, launches, delivers, findings, notes, feedbackNotes);
|
|
167
312
|
}
|
|
168
313
|
/**
|
|
169
314
|
* Copy an approved investigation's markdown into `<kilnDir>/findings`.
|
|
@@ -296,11 +441,357 @@ function observedDelivery(kilnDir, host, checkout, jobId, runId) {
|
|
|
296
441
|
...(viewError ? { viewError } : {}),
|
|
297
442
|
};
|
|
298
443
|
}
|
|
444
|
+
/**
|
|
445
|
+
* Attach a fix run id to an open round once its journal exists.
|
|
446
|
+
* A fix run that the attempt ledger has already recorded is replaced by a
|
|
447
|
+
* newer journal, so a retry does not stay pointed at the failed run.
|
|
448
|
+
*/
|
|
449
|
+
function bindOpenFixRuns(config, world) {
|
|
450
|
+
const runs = scanRuns(config);
|
|
451
|
+
for (const job of world.observation.jobs) {
|
|
452
|
+
const ledger = readFeedbackLedger(config.kilnDir, job.jobId);
|
|
453
|
+
if (!ledger || ledger === "corrupt")
|
|
454
|
+
continue;
|
|
455
|
+
const open = openFeedbackRound(ledger);
|
|
456
|
+
if (!open)
|
|
457
|
+
continue;
|
|
458
|
+
const attempts = readAttemptLedger(config.kilnDir, job.jobId);
|
|
459
|
+
const retired = new Set();
|
|
460
|
+
if (attempts && attempts !== "corrupt") {
|
|
461
|
+
for (const id of attempts.ignoredRunIds ?? [])
|
|
462
|
+
retired.add(id);
|
|
463
|
+
for (const attempt of attempts.attempts) {
|
|
464
|
+
if (attempt.runId)
|
|
465
|
+
retired.add(attempt.runId);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
if (open.fixRunId && !retired.has(open.fixRunId))
|
|
469
|
+
continue;
|
|
470
|
+
const baseRunId = readDelivery(config.kilnDir, job.jobId)?.runId;
|
|
471
|
+
const settled = new Set(ledger.rounds.flatMap((round) => (round.outcome === "pushed" || round.outcome === "fixed") && round.fixRunId ? [round.fixRunId] : []));
|
|
472
|
+
const candidates = (runs.get(jobTag(job.jobId)) ?? []).filter((run) => {
|
|
473
|
+
if (run.runId === baseRunId || settled.has(run.runId) || retired.has(run.runId))
|
|
474
|
+
return false;
|
|
475
|
+
return true;
|
|
476
|
+
});
|
|
477
|
+
candidates.sort((left, right) => (left.runId < right.runId ? 1 : left.runId > right.runId ? -1 : 0));
|
|
478
|
+
const next = candidates[0];
|
|
479
|
+
if (!next)
|
|
480
|
+
continue;
|
|
481
|
+
open.fixRunId = next.runId;
|
|
482
|
+
writeFeedbackLedger(config.kilnDir, ledger);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Fast-forward the open pull request branch to a fix run that finished ok.
|
|
487
|
+
* A missing branch or a history that is not a descendant is ambiguous.
|
|
488
|
+
* A rejected push that is not a fast-forward is ambiguous. Any other push
|
|
489
|
+
* error is left for the next tick. Kiln does not force-push.
|
|
490
|
+
*/
|
|
491
|
+
function publishReadyFixes(config, host, world) {
|
|
492
|
+
const notes = [];
|
|
493
|
+
for (const job of world.observation.jobs) {
|
|
494
|
+
if (!job.fixAwaitingPublish || !job.run)
|
|
495
|
+
continue;
|
|
496
|
+
const ledger = readFeedbackLedger(config.kilnDir, job.jobId);
|
|
497
|
+
if (!ledger || ledger === "corrupt")
|
|
498
|
+
continue;
|
|
499
|
+
const open = openFeedbackRound(ledger);
|
|
500
|
+
if (!open || open.fixRunId !== job.run.runId)
|
|
501
|
+
continue;
|
|
502
|
+
const record = readDelivery(config.kilnDir, job.jobId);
|
|
503
|
+
const checkout = job.run.checkout;
|
|
504
|
+
const source = `yak/${job.run.runId}`;
|
|
505
|
+
if (!record?.branch) {
|
|
506
|
+
notes.push(`feedback ${job.jobId} failed: no pull request branch to publish`);
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
let sha;
|
|
510
|
+
try {
|
|
511
|
+
sha = host.git.revParse(checkout, source);
|
|
512
|
+
}
|
|
513
|
+
catch (error) {
|
|
514
|
+
markFeedback(config.kilnDir, job.jobId, "feedback-ambiguous");
|
|
515
|
+
notes.push(`feedback-ambiguous ${job.jobId} ${errorMessage(error)}`);
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
if (sha === open.headSha) {
|
|
519
|
+
open.outcome = "fixed";
|
|
520
|
+
writeFeedbackLedger(config.kilnDir, ledger);
|
|
521
|
+
notes.push(`fixed ${job.jobId} ${sha}`);
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
const ancestor = host.git.isAncestor(checkout, open.headSha, sha);
|
|
525
|
+
if (!ancestor) {
|
|
526
|
+
markFeedback(config.kilnDir, job.jobId, "feedback-ambiguous");
|
|
527
|
+
notes.push(`feedback-ambiguous ${job.jobId} ${source} is not based on ${open.headSha}`);
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
try {
|
|
531
|
+
host.git.publishBranch(checkout, source, record.branch, record.base);
|
|
532
|
+
}
|
|
533
|
+
catch (error) {
|
|
534
|
+
const message = errorMessage(error);
|
|
535
|
+
if (isNonFastForward(message)) {
|
|
536
|
+
markFeedback(config.kilnDir, job.jobId, "feedback-ambiguous");
|
|
537
|
+
notes.push(`feedback-ambiguous ${job.jobId} ${message}`);
|
|
538
|
+
}
|
|
539
|
+
else {
|
|
540
|
+
notes.push(`feedback ${job.jobId} failed: ${message}`);
|
|
541
|
+
}
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
544
|
+
writeDelivery(config.kilnDir, {
|
|
545
|
+
jobId: record.jobId,
|
|
546
|
+
runId: record.runId,
|
|
547
|
+
branch: record.branch,
|
|
548
|
+
base: record.base,
|
|
549
|
+
sha,
|
|
550
|
+
pushed: true,
|
|
551
|
+
...(record.prNumber ? { prNumber: record.prNumber } : {}),
|
|
552
|
+
...(record.prUrl ? { prUrl: record.prUrl } : {}),
|
|
553
|
+
});
|
|
554
|
+
open.outcome = "pushed";
|
|
555
|
+
writeFeedbackLedger(config.kilnDir, ledger);
|
|
556
|
+
notes.push(`pushed ${job.jobId} ${record.branch} ${sha}`);
|
|
557
|
+
}
|
|
558
|
+
return notes;
|
|
559
|
+
}
|
|
560
|
+
function isNonFastForward(message) {
|
|
561
|
+
return /non-fast-forward|\[rejected\]|failed to push some refs/i.test(message);
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* Read each open pull request once per tick and attach a plan.
|
|
565
|
+
* `readStatus` does not call this. A host with no feedback reader does not watch.
|
|
566
|
+
*/
|
|
567
|
+
function watchPullRequests(config, world, host) {
|
|
568
|
+
const notes = [];
|
|
569
|
+
if (!host.feedback)
|
|
570
|
+
return notes;
|
|
571
|
+
for (const job of world.observation.jobs) {
|
|
572
|
+
if (job.feedbackLedgerError && job.deliver === "pr" && job.delivery?.prNumber) {
|
|
573
|
+
notes.push(`${job.feedbackLedgerError} ${job.jobId}`);
|
|
574
|
+
}
|
|
575
|
+
if (!shouldWatch(job) || !job.run)
|
|
576
|
+
continue;
|
|
577
|
+
const ledger = readFeedbackLedger(config.kilnDir, job.jobId);
|
|
578
|
+
if (ledger === "corrupt")
|
|
579
|
+
continue;
|
|
580
|
+
let snapshot;
|
|
581
|
+
try {
|
|
582
|
+
snapshot = host.feedback.read(job.run.checkout, job.delivery?.prNumber ?? 0);
|
|
583
|
+
}
|
|
584
|
+
catch (error) {
|
|
585
|
+
notes.push(`feedback ${job.jobId} failed: ${errorMessage(error)}`);
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
588
|
+
job.feedbackPlan = planFeedback({
|
|
589
|
+
maxFixRounds: config.maxFixRounds,
|
|
590
|
+
ledger,
|
|
591
|
+
snapshot,
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
return notes;
|
|
595
|
+
}
|
|
596
|
+
function shouldWatch(job) {
|
|
597
|
+
if (job.deliver !== "pr" || job.workflow === INVESTIGATION_WORKFLOW)
|
|
598
|
+
return false;
|
|
599
|
+
if (job.error || job.feedbackLedgerError || job.feedbackExhausted || job.feedbackAmbiguous)
|
|
600
|
+
return false;
|
|
601
|
+
if (job.fixInProgress || job.fixAwaitingPublish || job.launching)
|
|
602
|
+
return false;
|
|
603
|
+
if (!job.delivery?.prNumber)
|
|
604
|
+
return false;
|
|
605
|
+
if (job.delivery.merged || job.delivery.prState === "MERGED" || job.delivery.prState === "CLOSED")
|
|
606
|
+
return false;
|
|
607
|
+
return isLanded(job);
|
|
608
|
+
}
|
|
609
|
+
function markFeedback(kilnDir, jobId, reason) {
|
|
610
|
+
const existing = readFeedbackLedger(kilnDir, jobId);
|
|
611
|
+
const ledger = existing && existing !== "corrupt" ? existing : { jobId, rounds: [] };
|
|
612
|
+
if (reason === "feedback-exhausted")
|
|
613
|
+
ledger.exhausted = true;
|
|
614
|
+
else
|
|
615
|
+
ledger.ambiguous = true;
|
|
616
|
+
writeFeedbackLedger(kilnDir, ledger);
|
|
617
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
* Open one feedback round and start the fix workflow on the pull request head.
|
|
620
|
+
* The round is written only after that commit resolves, so a missing sha
|
|
621
|
+
* retries on the next tick without consuming a round.
|
|
622
|
+
*/
|
|
623
|
+
function startFix(config, world, job, host, now) {
|
|
624
|
+
const observed = world.observation.jobs.find((candidate) => candidate.jobId === job.id);
|
|
625
|
+
const plan = observed?.feedbackPlan;
|
|
626
|
+
const repo = config.repos[job.repo];
|
|
627
|
+
if (!repo || !plan || plan.action !== "fix") {
|
|
628
|
+
return { jobId: job.id, repo: job.repo, output: `Job ${job.id} has no fix to start.` };
|
|
629
|
+
}
|
|
630
|
+
let headSha;
|
|
631
|
+
try {
|
|
632
|
+
headSha = resolveCommit(host, repo.path, plan.headSha);
|
|
633
|
+
}
|
|
634
|
+
catch (error) {
|
|
635
|
+
return { jobId: job.id, repo: job.repo, output: errorMessage(error) };
|
|
636
|
+
}
|
|
637
|
+
const existing = readFeedbackLedger(config.kilnDir, job.id);
|
|
638
|
+
const ledger = existing && existing !== "corrupt" ? existing : { jobId: job.id, rounds: [] };
|
|
639
|
+
if (openFeedbackRound(ledger)) {
|
|
640
|
+
return { jobId: job.id, repo: job.repo, output: `Job ${job.id} already has an open fix round.` };
|
|
641
|
+
}
|
|
642
|
+
const round = ledger.rounds.length + 1;
|
|
643
|
+
ledger.rounds.push({
|
|
644
|
+
round,
|
|
645
|
+
headSha,
|
|
646
|
+
trigger: plan.trigger,
|
|
647
|
+
items: plan.items,
|
|
648
|
+
at: now.toISOString(),
|
|
649
|
+
});
|
|
650
|
+
writeFeedbackLedger(config.kilnDir, ledger);
|
|
651
|
+
let specText;
|
|
652
|
+
try {
|
|
653
|
+
specText = feedbackSpec(specHandedToYak(config, world, job), plan.items);
|
|
654
|
+
}
|
|
655
|
+
catch (error) {
|
|
656
|
+
const message = errorMessage(error);
|
|
657
|
+
writeLaunchError(config.kilnDir, job.id, message);
|
|
658
|
+
const logPath = join(config.kilnDir, "logs", `${jobBasename(job.id)}.log`);
|
|
659
|
+
mkdirSync(dirname(logPath), { recursive: true });
|
|
660
|
+
writeFileSync(logPath, `${message}\n`);
|
|
661
|
+
writeBreadcrumb(breadcrumbPath(config.kilnDir, job.id), { jobId: job.id, pid: 0, logPath });
|
|
662
|
+
return { jobId: job.id, repo: job.repo, kind: "fix", output: message };
|
|
663
|
+
}
|
|
664
|
+
const report = launchFixRun(config, job, host, repo.path, headSha, specText);
|
|
665
|
+
return { ...report, output: `round ${round}\n${report.output}` };
|
|
666
|
+
}
|
|
667
|
+
/** Relaunch the open fix round. Does not append a round. Failures become a dead launch so retry can see them. */
|
|
668
|
+
function launchOpenFix(config, world, job, host, checkout, open) {
|
|
669
|
+
let specText;
|
|
670
|
+
let headSha;
|
|
671
|
+
try {
|
|
672
|
+
clearLaunchError(config.kilnDir, job.id);
|
|
673
|
+
specText = feedbackSpec(specHandedToYak(config, world, job), open.items);
|
|
674
|
+
headSha = resolveCommit(host, checkout, open.headSha);
|
|
675
|
+
}
|
|
676
|
+
catch (error) {
|
|
677
|
+
const message = errorMessage(error);
|
|
678
|
+
writeLaunchError(config.kilnDir, job.id, message);
|
|
679
|
+
const logPath = join(config.kilnDir, "logs", `${jobBasename(job.id)}.log`);
|
|
680
|
+
mkdirSync(dirname(logPath), { recursive: true });
|
|
681
|
+
writeFileSync(logPath, `${message}\n`);
|
|
682
|
+
writeBreadcrumb(breadcrumbPath(config.kilnDir, job.id), { jobId: job.id, pid: 0, logPath });
|
|
683
|
+
return { jobId: job.id, repo: job.repo, kind: "fix", output: message };
|
|
684
|
+
}
|
|
685
|
+
return launchFixRun(config, job, host, checkout, headSha, specText);
|
|
686
|
+
}
|
|
687
|
+
function resolveCommit(host, checkout, sha) {
|
|
688
|
+
try {
|
|
689
|
+
return host.git.revParse(checkout, sha);
|
|
690
|
+
}
|
|
691
|
+
catch {
|
|
692
|
+
host.git.fetch(checkout);
|
|
693
|
+
return host.git.revParse(checkout, sha);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
/** `workflows/fix.yaml` on `headSha`. Commits on `yak/<run-id>`. Does not push. */
|
|
697
|
+
function launchFixRun(config, job, host, checkout, headSha, specText) {
|
|
698
|
+
const sourceWorkflow = readFileSync(join(packageRoot(), "workflows", "fix.yaml"), "utf8");
|
|
699
|
+
const workflow = materializeWorkflow(config.kilnDir, job.id, "fix");
|
|
700
|
+
const repo = config.repos[job.repo];
|
|
701
|
+
const logPath = join(config.kilnDir, "logs", `${jobBasename(job.id)}.log`);
|
|
702
|
+
mkdirSync(dirname(logPath), { recursive: true });
|
|
703
|
+
ensureCheckoutExcludes(checkout);
|
|
704
|
+
const input = workflowInputs(sourceWorkflow, {
|
|
705
|
+
title: job.title,
|
|
706
|
+
specText,
|
|
707
|
+
runNonce: randomUUID(),
|
|
708
|
+
baseRef: headSha,
|
|
709
|
+
});
|
|
710
|
+
const args = agentArgs(config, job, workflow, headSha, input);
|
|
711
|
+
const env = {
|
|
712
|
+
...process.env,
|
|
713
|
+
...(repo?.test ? { KILN_TEST: repo.test } : {}),
|
|
714
|
+
KILN_TASK_TITLE: job.title,
|
|
715
|
+
KILN_VERIFY_WRAPPER: join(packageRoot(), "dist", "truncate-output.js"),
|
|
716
|
+
};
|
|
717
|
+
return spawnLogged(config, host, {
|
|
718
|
+
jobId: job.id,
|
|
719
|
+
repo: job.repo,
|
|
720
|
+
kind: "fix",
|
|
721
|
+
cwd: checkout,
|
|
722
|
+
args,
|
|
723
|
+
env,
|
|
724
|
+
logPath,
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
function agentArgs(config, job, workflow, headSha, input) {
|
|
728
|
+
const args = [
|
|
729
|
+
yakCliPath(),
|
|
730
|
+
"run",
|
|
731
|
+
workflow,
|
|
732
|
+
"--adapter",
|
|
733
|
+
config.adapter,
|
|
734
|
+
"--isolation",
|
|
735
|
+
"worktree",
|
|
736
|
+
"--from",
|
|
737
|
+
headSha,
|
|
738
|
+
"--tag",
|
|
739
|
+
jobTag(job.id),
|
|
740
|
+
];
|
|
741
|
+
for (const [key, value] of Object.entries(input)) {
|
|
742
|
+
args.push("--input", `${key}=${value}`);
|
|
743
|
+
}
|
|
744
|
+
return args;
|
|
745
|
+
}
|
|
746
|
+
function spawnLogged(config, host, spec) {
|
|
747
|
+
const crumbPath = breadcrumbPath(config.kilnDir, spec.jobId);
|
|
748
|
+
writeBreadcrumb(crumbPath, { jobId: spec.jobId, pid: 0, logPath: spec.logPath });
|
|
749
|
+
const logFd = openSync(spec.logPath, "a");
|
|
750
|
+
try {
|
|
751
|
+
const started = spawnAgent(host, { cwd: spec.cwd, args: spec.args, env: spec.env, logFd });
|
|
752
|
+
const pid = started.pid ?? 0;
|
|
753
|
+
writeBreadcrumb(crumbPath, { jobId: spec.jobId, pid, logPath: spec.logPath });
|
|
754
|
+
return {
|
|
755
|
+
jobId: spec.jobId,
|
|
756
|
+
repo: spec.repo,
|
|
757
|
+
...(spec.kind ? { kind: spec.kind } : {}),
|
|
758
|
+
output: `pid ${pid}\nlog ${spec.logPath}`,
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
catch (error) {
|
|
762
|
+
writeFileSync(spec.logPath, `${errorMessage(error)}\n`);
|
|
763
|
+
return {
|
|
764
|
+
jobId: spec.jobId,
|
|
765
|
+
repo: spec.repo,
|
|
766
|
+
...(spec.kind ? { kind: spec.kind } : {}),
|
|
767
|
+
output: `pid 0\nlog ${spec.logPath}`,
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
finally {
|
|
771
|
+
closeSync(logFd);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
function spawnAgent(host, spec) {
|
|
775
|
+
if (host.startAgent)
|
|
776
|
+
return host.startAgent(spec);
|
|
777
|
+
const child = spawn(process.execPath, spec.args, {
|
|
778
|
+
cwd: spec.cwd,
|
|
779
|
+
detached: true,
|
|
780
|
+
stdio: ["ignore", spec.logFd, spec.logFd],
|
|
781
|
+
env: spec.env,
|
|
782
|
+
});
|
|
783
|
+
child.unref();
|
|
784
|
+
return { pid: child.pid ?? 0 };
|
|
785
|
+
}
|
|
299
786
|
function launchJob(config, world, job, host) {
|
|
300
787
|
const repo = config.repos[job.repo];
|
|
301
788
|
if (!repo) {
|
|
302
789
|
throw new Error(`Repo ${job.repo} is not registered. Run kiln repo add ${job.repo} <path>.`);
|
|
303
790
|
}
|
|
791
|
+
const feedback = readFeedbackLedger(config.kilnDir, job.id);
|
|
792
|
+
const open = feedback && feedback !== "corrupt" ? openFeedbackRound(feedback) : undefined;
|
|
793
|
+
if (open)
|
|
794
|
+
return launchOpenFix(config, world, job, host, repo.path, open);
|
|
304
795
|
const sourceWorkflow = readFileSync(join(packageRoot(), "workflows", `${job.workflow}.yaml`), "utf8");
|
|
305
796
|
const testCommand = repo.test;
|
|
306
797
|
if (workflowNeedsTestCommand(sourceWorkflow) && !testCommand?.trim()) {
|
|
@@ -309,7 +800,6 @@ function launchJob(config, world, job, host) {
|
|
|
309
800
|
const workflow = materializeWorkflow(config.kilnDir, job.id, job.workflow);
|
|
310
801
|
const logPath = join(config.kilnDir, "logs", `${jobBasename(job.id)}.log`);
|
|
311
802
|
mkdirSync(dirname(logPath), { recursive: true });
|
|
312
|
-
const crumbPath = breadcrumbPath(config.kilnDir, job.id);
|
|
313
803
|
ensureCheckoutExcludes(repo.path);
|
|
314
804
|
let baseSha;
|
|
315
805
|
let specText;
|
|
@@ -338,49 +828,14 @@ function launchJob(config, world, job, host) {
|
|
|
338
828
|
runNonce: randomUUID(),
|
|
339
829
|
baseRef: baseSha,
|
|
340
830
|
});
|
|
341
|
-
const args =
|
|
342
|
-
yakCliPath(),
|
|
343
|
-
"run",
|
|
344
|
-
workflow,
|
|
345
|
-
"--adapter",
|
|
346
|
-
config.adapter,
|
|
347
|
-
"--isolation",
|
|
348
|
-
"worktree",
|
|
349
|
-
"--from",
|
|
350
|
-
baseSha,
|
|
351
|
-
"--tag",
|
|
352
|
-
jobTag(job.id),
|
|
353
|
-
];
|
|
354
|
-
for (const [key, value] of Object.entries(input)) {
|
|
355
|
-
args.push("--input", `${key}=${value}`);
|
|
356
|
-
}
|
|
831
|
+
const args = agentArgs(config, job, workflow, baseSha, input);
|
|
357
832
|
const env = {
|
|
358
833
|
...process.env,
|
|
359
834
|
KILN_TEST: testCommand,
|
|
360
835
|
KILN_TASK_TITLE: job.title,
|
|
361
836
|
KILN_VERIFY_WRAPPER: join(packageRoot(), "dist", "truncate-output.js"),
|
|
362
837
|
};
|
|
363
|
-
|
|
364
|
-
const logFd = openSync(logPath, "a");
|
|
365
|
-
try {
|
|
366
|
-
const child = spawn(process.execPath, args, {
|
|
367
|
-
cwd: repo.path,
|
|
368
|
-
detached: true,
|
|
369
|
-
stdio: ["ignore", logFd, logFd],
|
|
370
|
-
env,
|
|
371
|
-
});
|
|
372
|
-
child.unref();
|
|
373
|
-
const pid = child.pid ?? 0;
|
|
374
|
-
writeBreadcrumb(crumbPath, { jobId: job.id, pid, logPath });
|
|
375
|
-
return { jobId: job.id, repo: job.repo, output: `pid ${pid}\nlog ${logPath}` };
|
|
376
|
-
}
|
|
377
|
-
catch (error) {
|
|
378
|
-
writeFileSync(logPath, `${errorMessage(error)}\n`);
|
|
379
|
-
return { jobId: job.id, repo: job.repo, output: `pid 0\nlog ${logPath}` };
|
|
380
|
-
}
|
|
381
|
-
finally {
|
|
382
|
-
closeSync(logFd);
|
|
383
|
-
}
|
|
838
|
+
return spawnLogged(config, host, { jobId: job.id, repo: job.repo, cwd: repo.path, args, env, logPath });
|
|
384
839
|
}
|
|
385
840
|
function loadJobs(config) {
|
|
386
841
|
if (!existsSync(config.jobsDir))
|
|
@@ -429,6 +884,12 @@ function scanRuns(config) {
|
|
|
429
884
|
const status = classify(events);
|
|
430
885
|
const gateDecision = readApproveDecision(runDir);
|
|
431
886
|
const title = readRunTitle(runDir);
|
|
887
|
+
const finished = [...events].reverse().find((event) => event.t === "run.finished");
|
|
888
|
+
const stepPlan = readRunStepPlan(runDir);
|
|
889
|
+
const stepEvents = stepEventsFrom(events);
|
|
890
|
+
const pid = livePid(events);
|
|
891
|
+
const activityMs = eventActivityMs(events) ?? fileMtimeMs(join(runDir, "journal.jsonl"));
|
|
892
|
+
const finishedReason = finished && typeof finished.reason === "string" ? finished.reason : undefined;
|
|
432
893
|
const run = {
|
|
433
894
|
runId,
|
|
434
895
|
checkout: repo.path,
|
|
@@ -436,14 +897,211 @@ function scanRuns(config) {
|
|
|
436
897
|
gates: status === "suspended" ? openGates(runDir, events) : [],
|
|
437
898
|
...(gateDecision ? { gateDecision } : {}),
|
|
438
899
|
...(title ? { title } : {}),
|
|
900
|
+
...(typeof started?.at === "string" ? { startedAt: started.at } : {}),
|
|
901
|
+
...(typeof finished?.at === "string" ? { finishedAt: finished.at } : {}),
|
|
902
|
+
...(stepPlan.length > 0 ? { stepPlan } : {}),
|
|
903
|
+
...(stepEvents.length > 0 ? { stepEvents } : {}),
|
|
904
|
+
...(pid !== undefined ? { pid } : {}),
|
|
905
|
+
...(activityMs !== undefined ? { activityMs } : {}),
|
|
906
|
+
...(finishedReason ? { finishedReason } : {}),
|
|
439
907
|
};
|
|
440
|
-
const
|
|
441
|
-
|
|
442
|
-
|
|
908
|
+
const list = byTag.get(started.tag) ?? [];
|
|
909
|
+
list.push(run);
|
|
910
|
+
byTag.set(started.tag, list);
|
|
443
911
|
}
|
|
444
912
|
}
|
|
445
913
|
return byTag;
|
|
446
914
|
}
|
|
915
|
+
function stepEventsFrom(events) {
|
|
916
|
+
const found = [];
|
|
917
|
+
for (const event of events) {
|
|
918
|
+
if (event.t !== "step.started" && event.t !== "step.completed" && event.t !== "step.failed")
|
|
919
|
+
continue;
|
|
920
|
+
if (!event.stepId)
|
|
921
|
+
continue;
|
|
922
|
+
const detail = event.t === "step.failed" ? formatFailure(event.failure) : undefined;
|
|
923
|
+
found.push({
|
|
924
|
+
t: event.t,
|
|
925
|
+
stepId: event.stepId,
|
|
926
|
+
...(detail ? { detail } : {}),
|
|
927
|
+
});
|
|
928
|
+
}
|
|
929
|
+
return found;
|
|
930
|
+
}
|
|
931
|
+
function formatFailure(failure) {
|
|
932
|
+
if (!failure)
|
|
933
|
+
return undefined;
|
|
934
|
+
const detail = typeof failure.detail === "string" ? failure.detail.trim() : "";
|
|
935
|
+
const reason = typeof failure.reason === "string" ? failure.reason.trim() : "";
|
|
936
|
+
const text = detail && reason ? `${detail} (${reason})` : detail || reason;
|
|
937
|
+
return text || undefined;
|
|
938
|
+
}
|
|
939
|
+
function readRunStepPlan(runDir) {
|
|
940
|
+
const file = join(runDir, "workflow.json");
|
|
941
|
+
if (!existsSync(file))
|
|
942
|
+
return [];
|
|
943
|
+
try {
|
|
944
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
945
|
+
return leafStepIds(parsed.steps);
|
|
946
|
+
}
|
|
947
|
+
catch {
|
|
948
|
+
return [];
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
function readDiffSummary(checkout, runId) {
|
|
952
|
+
const worktree = join(checkout, ".yak", "worktrees", runId);
|
|
953
|
+
if (existsSync(worktree)) {
|
|
954
|
+
const result = spawnSync("git", ["diff", "--stat", "HEAD"], {
|
|
955
|
+
cwd: worktree,
|
|
956
|
+
encoding: "utf8",
|
|
957
|
+
timeout: 5_000,
|
|
958
|
+
});
|
|
959
|
+
const text = result.status === 0 ? result.stdout.trim() : "";
|
|
960
|
+
if (text)
|
|
961
|
+
return clipText(text, 1500);
|
|
962
|
+
}
|
|
963
|
+
return readBuildSummary(join(checkout, ".runs", runId));
|
|
964
|
+
}
|
|
965
|
+
function readBuildSummary(runDir) {
|
|
966
|
+
const dir = join(runDir, "artifacts");
|
|
967
|
+
if (!existsSync(dir))
|
|
968
|
+
return undefined;
|
|
969
|
+
let files;
|
|
970
|
+
try {
|
|
971
|
+
files = readdirSync(dir);
|
|
972
|
+
}
|
|
973
|
+
catch {
|
|
974
|
+
return undefined;
|
|
975
|
+
}
|
|
976
|
+
let best;
|
|
977
|
+
for (const file of files) {
|
|
978
|
+
const match = /^build(?:\.(\d+))?\.json$/.exec(file);
|
|
979
|
+
if (!match)
|
|
980
|
+
continue;
|
|
981
|
+
const iter = match[1] ? Number(match[1]) : 0;
|
|
982
|
+
if (!best || iter >= best.iter)
|
|
983
|
+
best = { iter, file };
|
|
984
|
+
}
|
|
985
|
+
if (!best)
|
|
986
|
+
return undefined;
|
|
987
|
+
try {
|
|
988
|
+
const parsed = JSON.parse(readFileSync(join(dir, best.file), "utf8"));
|
|
989
|
+
if (typeof parsed.summary !== "string" || !parsed.summary.trim())
|
|
990
|
+
return undefined;
|
|
991
|
+
return clipText(parsed.summary.trim(), 280);
|
|
992
|
+
}
|
|
993
|
+
catch {
|
|
994
|
+
return undefined;
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
function readLogTail(logPath) {
|
|
998
|
+
if (!logPath)
|
|
999
|
+
return undefined;
|
|
1000
|
+
let size;
|
|
1001
|
+
try {
|
|
1002
|
+
size = statSync(logPath).size;
|
|
1003
|
+
}
|
|
1004
|
+
catch {
|
|
1005
|
+
return undefined;
|
|
1006
|
+
}
|
|
1007
|
+
if (size <= 0)
|
|
1008
|
+
return undefined;
|
|
1009
|
+
const take = Math.min(size, 64_000);
|
|
1010
|
+
let fd;
|
|
1011
|
+
try {
|
|
1012
|
+
fd = openSync(logPath, "r");
|
|
1013
|
+
const buffer = Buffer.alloc(take);
|
|
1014
|
+
readSync(fd, buffer, 0, take, size - take);
|
|
1015
|
+
const text = buffer.toString("utf8");
|
|
1016
|
+
const lines = text.split(/\r?\n/);
|
|
1017
|
+
if (size > take)
|
|
1018
|
+
lines.shift();
|
|
1019
|
+
while (lines.length > 0 && lines[lines.length - 1] === "")
|
|
1020
|
+
lines.pop();
|
|
1021
|
+
const tail = lines.slice(-40).join("\n").trim();
|
|
1022
|
+
return tail || undefined;
|
|
1023
|
+
}
|
|
1024
|
+
catch {
|
|
1025
|
+
return undefined;
|
|
1026
|
+
}
|
|
1027
|
+
finally {
|
|
1028
|
+
if (fd !== undefined)
|
|
1029
|
+
closeSync(fd);
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
/**
|
|
1033
|
+
* Prefer a run that is not already a recorded failure, so a retry is visible
|
|
1034
|
+
* even when its id does not sort after the failed one. Otherwise keep the
|
|
1035
|
+
* latest id, which is what a single-run job has always shown.
|
|
1036
|
+
*/
|
|
1037
|
+
function selectJobRun(runs, ledger, feedback, baseRunId) {
|
|
1038
|
+
const settled = new Set();
|
|
1039
|
+
for (const round of feedback?.rounds ?? []) {
|
|
1040
|
+
if ((round.outcome === "pushed" || round.outcome === "fixed") && round.fixRunId)
|
|
1041
|
+
settled.add(round.fixRunId);
|
|
1042
|
+
}
|
|
1043
|
+
const ignored = new Set(ledger?.ignoredRunIds ?? []);
|
|
1044
|
+
const failed = new Set((ledger?.attempts ?? []).flatMap((attempt) => (attempt.runId ? [attempt.runId] : [])));
|
|
1045
|
+
const open = openFeedbackRound(feedback);
|
|
1046
|
+
if (open) {
|
|
1047
|
+
const bound = open.fixRunId && !ignored.has(open.fixRunId) && !failed.has(open.fixRunId) ? open.fixRunId : undefined;
|
|
1048
|
+
const fixRuns = runs.filter((run) => {
|
|
1049
|
+
if (settled.has(run.runId) || ignored.has(run.runId))
|
|
1050
|
+
return false;
|
|
1051
|
+
if (bound)
|
|
1052
|
+
return run.runId === bound;
|
|
1053
|
+
if (failed.has(run.runId))
|
|
1054
|
+
return false;
|
|
1055
|
+
return baseRunId ? run.runId !== baseRunId : true;
|
|
1056
|
+
});
|
|
1057
|
+
fixRuns.sort((left, right) => (left.runId < right.runId ? 1 : left.runId > right.runId ? -1 : 0));
|
|
1058
|
+
const fix = fixRuns[0];
|
|
1059
|
+
if (fix) {
|
|
1060
|
+
const retired = failed.has(fix.runId);
|
|
1061
|
+
return {
|
|
1062
|
+
run: fix,
|
|
1063
|
+
retired,
|
|
1064
|
+
fixInProgress: true,
|
|
1065
|
+
fixAwaitingPublish: fix.status === "ok" && !retired,
|
|
1066
|
+
};
|
|
1067
|
+
}
|
|
1068
|
+
return { retired: false, fixInProgress: true, fixAwaitingPublish: false };
|
|
1069
|
+
}
|
|
1070
|
+
const picked = selectRun(runs.filter((run) => !settled.has(run.runId)), ledger);
|
|
1071
|
+
return { ...picked, fixInProgress: false, fixAwaitingPublish: false };
|
|
1072
|
+
}
|
|
1073
|
+
function selectRun(runs, ledger) {
|
|
1074
|
+
const ignored = new Set(ledger?.ignoredRunIds ?? []);
|
|
1075
|
+
const failed = new Set((ledger?.attempts ?? []).flatMap((attempt) => (attempt.runId ? [attempt.runId] : [])));
|
|
1076
|
+
const visible = runs.filter((run) => !ignored.has(run.runId));
|
|
1077
|
+
visible.sort((left, right) => (left.runId < right.runId ? 1 : left.runId > right.runId ? -1 : 0));
|
|
1078
|
+
const fresh = visible.filter((run) => !failed.has(run.runId));
|
|
1079
|
+
if (fresh[0])
|
|
1080
|
+
return { run: fresh[0], retired: false };
|
|
1081
|
+
if (visible[0])
|
|
1082
|
+
return { run: visible[0], retired: true };
|
|
1083
|
+
return { retired: false };
|
|
1084
|
+
}
|
|
1085
|
+
function later(left, right) {
|
|
1086
|
+
if (left === undefined)
|
|
1087
|
+
return right;
|
|
1088
|
+
if (right === undefined)
|
|
1089
|
+
return left;
|
|
1090
|
+
return Math.max(left, right);
|
|
1091
|
+
}
|
|
1092
|
+
function eventActivityMs(events) {
|
|
1093
|
+
let latest;
|
|
1094
|
+
for (const event of events) {
|
|
1095
|
+
if (!event.at)
|
|
1096
|
+
continue;
|
|
1097
|
+
const parsed = Date.parse(event.at);
|
|
1098
|
+
if (!Number.isFinite(parsed))
|
|
1099
|
+
continue;
|
|
1100
|
+
if (latest === undefined || parsed > latest)
|
|
1101
|
+
latest = parsed;
|
|
1102
|
+
}
|
|
1103
|
+
return latest;
|
|
1104
|
+
}
|
|
447
1105
|
function listRunDirs(checkout) {
|
|
448
1106
|
const runsDir = join(checkout, ".runs");
|
|
449
1107
|
if (!existsSync(runsDir))
|
|
@@ -807,15 +1465,7 @@ function releaseLock(lockPath) {
|
|
|
807
1465
|
}
|
|
808
1466
|
}
|
|
809
1467
|
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
|
-
}
|
|
1468
|
+
return isPidAlive(pid);
|
|
819
1469
|
}
|
|
820
1470
|
function isErrno(error, code) {
|
|
821
1471
|
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|