@lchase/kiln 0.4.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 +7 -5
- package/dist/attention.js +10 -0
- package/dist/cli.js +4 -0
- package/dist/config.js +2 -1
- package/dist/driver.js +424 -48
- package/dist/feedback.js +610 -0
- package/dist/host.js +26 -1
- package/dist/reconcile.js +31 -3
- package/dist/report.js +48 -9
- package/dist/schemas.js +7 -0
- package/dist/serve-page.js +7 -1
- package/dist/workflow.js +2 -0
- package/package.json +1 -1
- package/prompts/kiln/fix.md +17 -0
- package/workflows/fix.yaml +44 -0
package/dist/driver.js
CHANGED
|
@@ -4,6 +4,7 @@ import { closeSync, existsSync, linkSync, mkdirSync, openSync, readdirSync, read
|
|
|
4
4
|
import { basename, dirname, isAbsolute, join } from "node:path";
|
|
5
5
|
import { buildAttention } from "./attention.js";
|
|
6
6
|
import { clearLaunchError, deliverJob, readDelivery, readLaunchError, resolveLaunchBase, writeDelivery, writeLaunchError, isMerged, } from "./delivery.js";
|
|
7
|
+
import { feedbackSpec, openFeedbackRound, planFeedback, readFeedbackLedger, writeFeedbackLedger, } from "./feedback.js";
|
|
7
8
|
import { appendInvestigationFindings, existingFindingsPath, findingsFilePath, INVESTIGATION_WORKFLOW, readFindingsMarkdown, writeFindings, } from "./findings.js";
|
|
8
9
|
import { defaultHost } from "./host.js";
|
|
9
10
|
import { readJobSpec, resolveJobSpec } from "./jobs.js";
|
|
@@ -38,9 +39,13 @@ export function runTick(config, host = defaultHost(), options = {}) {
|
|
|
38
39
|
const now = options.now ?? new Date();
|
|
39
40
|
return withTickLock(config.kilnDir, () => {
|
|
40
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);
|
|
41
45
|
const world = observe(config, host);
|
|
46
|
+
const feedbackNotes = watchPullRequests(config, world, host);
|
|
42
47
|
const actions = decide(world.observation, now);
|
|
43
|
-
return apply(config, world, actions, host, notes);
|
|
48
|
+
return apply(config, world, actions, host, notes, now, [...published, ...feedbackNotes]);
|
|
44
49
|
});
|
|
45
50
|
}
|
|
46
51
|
/** Startup pass shared by `kiln tick` (via `runTick`) and `kiln serve`. Idempotent. */
|
|
@@ -168,20 +173,25 @@ function observe(config, host) {
|
|
|
168
173
|
const tag = jobTag(entry.meta.jobId);
|
|
169
174
|
const ledgerResult = readAttemptLedger(config.kilnDir, entry.meta.jobId);
|
|
170
175
|
const ledger = ledgerResult && ledgerResult !== "corrupt" ? ledgerResult : undefined;
|
|
171
|
-
const
|
|
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);
|
|
172
183
|
const run = picked.run;
|
|
173
184
|
const crumb = breadcrumbs.find((candidate) => candidate.jobId === entry.meta.jobId);
|
|
174
185
|
if (crumb && run && !picked.retired)
|
|
175
186
|
breadcrumbsToClear.push({ path: crumb.path });
|
|
176
187
|
const childAlive = crumb ? pidAlive(crumb.pid) : false;
|
|
177
|
-
const
|
|
178
|
-
const
|
|
179
|
-
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);
|
|
180
191
|
const repoError = entry.job && !repoKnown ? `Repo ${entry.job.repo} is not registered.` : undefined;
|
|
181
|
-
const
|
|
182
|
-
const deliver = repo?.deliver ?? "local";
|
|
192
|
+
const deliveryRunId = picked.fixInProgress ? deliveryRecord?.runId : run?.runId;
|
|
183
193
|
const delivery = entry.job && repo && deliver === "pr"
|
|
184
|
-
? observedDelivery(config.kilnDir, host, repo.path, entry.job.id,
|
|
194
|
+
? observedDelivery(config.kilnDir, host, repo.path, entry.job.id, deliveryRunId)
|
|
185
195
|
: undefined;
|
|
186
196
|
const launchError = entry.job && !run ? readLaunchError(config.kilnDir, entry.job.id) : undefined;
|
|
187
197
|
const stackError = entry.job?.stack && (entry.job.dependsOn?.length ?? 0) !== 1
|
|
@@ -237,6 +247,12 @@ function observe(config, host) {
|
|
|
237
247
|
...(exhausted ? { retriesExhausted: true } : {}),
|
|
238
248
|
...(picked.retired ? { runRetired: true } : {}),
|
|
239
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 } : {}),
|
|
240
256
|
...(entry.meta.error || repoError || stackError
|
|
241
257
|
? { error: entry.meta.error ?? repoError ?? stackError }
|
|
242
258
|
: {}),
|
|
@@ -252,7 +268,7 @@ function observe(config, host) {
|
|
|
252
268
|
jobs: records,
|
|
253
269
|
};
|
|
254
270
|
}
|
|
255
|
-
function apply(config, world, actions, host, notes = []) {
|
|
271
|
+
function apply(config, world, actions, host, notes = [], now = new Date(), feedbackNotes = []) {
|
|
256
272
|
const launches = [];
|
|
257
273
|
const delivers = [];
|
|
258
274
|
const findings = [];
|
|
@@ -271,6 +287,20 @@ function apply(config, world, actions, host, notes = []) {
|
|
|
271
287
|
findings.push(recorded);
|
|
272
288
|
continue;
|
|
273
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
|
+
}
|
|
274
304
|
const job = world.jobs.get(action.jobId);
|
|
275
305
|
if (!job) {
|
|
276
306
|
throw new Error(`Tick planned a launch for ${action.jobId}, which is not in the observed jobs.`);
|
|
@@ -278,7 +308,7 @@ function apply(config, world, actions, host, notes = []) {
|
|
|
278
308
|
const report = launchJob(config, world, job, host);
|
|
279
309
|
launches.push(action.type === "retry" ? { ...report, retry: true } : report);
|
|
280
310
|
}
|
|
281
|
-
return renderTick(world.observation, actions, launches, delivers, findings, notes);
|
|
311
|
+
return renderTick(world.observation, actions, launches, delivers, findings, notes, feedbackNotes);
|
|
282
312
|
}
|
|
283
313
|
/**
|
|
284
314
|
* Copy an approved investigation's markdown into `<kilnDir>/findings`.
|
|
@@ -411,11 +441,357 @@ function observedDelivery(kilnDir, host, checkout, jobId, runId) {
|
|
|
411
441
|
...(viewError ? { viewError } : {}),
|
|
412
442
|
};
|
|
413
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
|
+
}
|
|
414
786
|
function launchJob(config, world, job, host) {
|
|
415
787
|
const repo = config.repos[job.repo];
|
|
416
788
|
if (!repo) {
|
|
417
789
|
throw new Error(`Repo ${job.repo} is not registered. Run kiln repo add ${job.repo} <path>.`);
|
|
418
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);
|
|
419
795
|
const sourceWorkflow = readFileSync(join(packageRoot(), "workflows", `${job.workflow}.yaml`), "utf8");
|
|
420
796
|
const testCommand = repo.test;
|
|
421
797
|
if (workflowNeedsTestCommand(sourceWorkflow) && !testCommand?.trim()) {
|
|
@@ -424,7 +800,6 @@ function launchJob(config, world, job, host) {
|
|
|
424
800
|
const workflow = materializeWorkflow(config.kilnDir, job.id, job.workflow);
|
|
425
801
|
const logPath = join(config.kilnDir, "logs", `${jobBasename(job.id)}.log`);
|
|
426
802
|
mkdirSync(dirname(logPath), { recursive: true });
|
|
427
|
-
const crumbPath = breadcrumbPath(config.kilnDir, job.id);
|
|
428
803
|
ensureCheckoutExcludes(repo.path);
|
|
429
804
|
let baseSha;
|
|
430
805
|
let specText;
|
|
@@ -453,49 +828,14 @@ function launchJob(config, world, job, host) {
|
|
|
453
828
|
runNonce: randomUUID(),
|
|
454
829
|
baseRef: baseSha,
|
|
455
830
|
});
|
|
456
|
-
const args =
|
|
457
|
-
yakCliPath(),
|
|
458
|
-
"run",
|
|
459
|
-
workflow,
|
|
460
|
-
"--adapter",
|
|
461
|
-
config.adapter,
|
|
462
|
-
"--isolation",
|
|
463
|
-
"worktree",
|
|
464
|
-
"--from",
|
|
465
|
-
baseSha,
|
|
466
|
-
"--tag",
|
|
467
|
-
jobTag(job.id),
|
|
468
|
-
];
|
|
469
|
-
for (const [key, value] of Object.entries(input)) {
|
|
470
|
-
args.push("--input", `${key}=${value}`);
|
|
471
|
-
}
|
|
831
|
+
const args = agentArgs(config, job, workflow, baseSha, input);
|
|
472
832
|
const env = {
|
|
473
833
|
...process.env,
|
|
474
834
|
KILN_TEST: testCommand,
|
|
475
835
|
KILN_TASK_TITLE: job.title,
|
|
476
836
|
KILN_VERIFY_WRAPPER: join(packageRoot(), "dist", "truncate-output.js"),
|
|
477
837
|
};
|
|
478
|
-
|
|
479
|
-
const logFd = openSync(logPath, "a");
|
|
480
|
-
try {
|
|
481
|
-
const child = spawn(process.execPath, args, {
|
|
482
|
-
cwd: repo.path,
|
|
483
|
-
detached: true,
|
|
484
|
-
stdio: ["ignore", logFd, logFd],
|
|
485
|
-
env,
|
|
486
|
-
});
|
|
487
|
-
child.unref();
|
|
488
|
-
const pid = child.pid ?? 0;
|
|
489
|
-
writeBreadcrumb(crumbPath, { jobId: job.id, pid, logPath });
|
|
490
|
-
return { jobId: job.id, repo: job.repo, output: `pid ${pid}\nlog ${logPath}` };
|
|
491
|
-
}
|
|
492
|
-
catch (error) {
|
|
493
|
-
writeFileSync(logPath, `${errorMessage(error)}\n`);
|
|
494
|
-
return { jobId: job.id, repo: job.repo, output: `pid 0\nlog ${logPath}` };
|
|
495
|
-
}
|
|
496
|
-
finally {
|
|
497
|
-
closeSync(logFd);
|
|
498
|
-
}
|
|
838
|
+
return spawnLogged(config, host, { jobId: job.id, repo: job.repo, cwd: repo.path, args, env, logPath });
|
|
499
839
|
}
|
|
500
840
|
function loadJobs(config) {
|
|
501
841
|
if (!existsSync(config.jobsDir))
|
|
@@ -694,6 +1034,42 @@ function readLogTail(logPath) {
|
|
|
694
1034
|
* even when its id does not sort after the failed one. Otherwise keep the
|
|
695
1035
|
* latest id, which is what a single-run job has always shown.
|
|
696
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
|
+
}
|
|
697
1073
|
function selectRun(runs, ledger) {
|
|
698
1074
|
const ignored = new Set(ledger?.ignoredRunIds ?? []);
|
|
699
1075
|
const failed = new Set((ledger?.attempts ?? []).flatMap((attempt) => (attempt.runId ? [attempt.runId] : [])));
|