@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/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
- return statusView(observe(config, host).observation, jobId);
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 actions = decide(world.observation);
37
- return apply(config, world, actions, host);
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 run = runs.get(tag);
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 repoKnown = entry.job ? Boolean(config.repos[entry.job.repo]) : false;
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 repo = entry.job ? config.repos[entry.job.repo] : undefined;
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, run?.runId)
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: Boolean(crumb) && !run && childAlive,
123
- launchFailed: Boolean(crumb) && !run && !childAlive,
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
- launches.push(launchJob(config, world, job, host));
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
- writeBreadcrumb(crumbPath, { jobId: job.id, pid: 0, logPath });
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 previous = byTag.get(started.tag);
441
- if (!previous || run.runId > previous.runId)
442
- byTag.set(started.tag, run);
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
- if (!Number.isInteger(pid) || pid <= 0)
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;