@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/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 picked = selectRun(runs.get(tag) ?? [], ledger);
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 launching = Boolean(crumb) && childAlive && (!run || picked.retired);
178
- const launchFailed = Boolean(crumb) && !childAlive && (!run || picked.retired);
179
- 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);
180
191
  const repoError = entry.job && !repoKnown ? `Repo ${entry.job.repo} is not registered.` : undefined;
181
- const repo = entry.job ? config.repos[entry.job.repo] : undefined;
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, run?.runId)
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
- writeBreadcrumb(crumbPath, { jobId: job.id, pid: 0, logPath });
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] : [])));