@lchase/kiln 0.4.0 → 0.6.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,13 +4,15 @@ 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";
10
11
  import { jobBasename, packageRoot } from "./paths.js";
11
12
  import { decide, isLanded, jobState, } from "./reconcile.js";
12
13
  import { fileMtimeMs, isPidAlive, readAttemptLedger, recoverRuns, resetAttemptLedger, } from "./reliability.js";
13
- import { gateAnswerCommand, renderStatus, renderTick, statusView, yakCliPath, } from "./report.js";
14
+ import { gateAnswerCommand, renderStatus, renderTick, statusView, yakCliPath, sandboxFailureDetail, } from "./report.js";
15
+ import { AGENT_SANDBOX_IMAGE, injectAgentSandbox, jobIsSandboxed, probeSandboxEnvironment, PROXY_SANDBOX_IMAGE, requiredSandboxImages, resolveSandbox, sandboxIsEnabled, sandboxPreflight, sandboxPreflightFor, } from "./sandbox.js";
14
16
  import { JobSchema } from "./schemas.js";
15
17
  import { leafStepIds, materializeWorkflow, workflowInputs, workflowNeedsTestCommand, workflowStepPlan } from "./workflow.js";
16
18
  const TAG_PREFIX = "kiln:";
@@ -38,9 +40,18 @@ export function runTick(config, host = defaultHost(), options = {}) {
38
40
  const now = options.now ?? new Date();
39
41
  return withTickLock(config.kilnDir, () => {
40
42
  const notes = recoverRuns(config, observe(config, host).observation.jobs, { startup, now });
43
+ const facts = sandboxIsEnabled(config)
44
+ ? (options.sandboxFacts ?? probeSandboxEnvironment(process.env, requiredSandboxImages(config)))
45
+ : undefined;
46
+ const midway = observe(config, host);
47
+ bindOpenFixRuns(config, midway);
48
+ const published = publishReadyFixes(config, host, midway);
41
49
  const world = observe(config, host);
50
+ const feedbackNotes = watchPullRequests(config, world, host);
42
51
  const actions = decide(world.observation, now);
43
- return apply(config, world, actions, host, notes);
52
+ const body = apply(config, world, actions, host, notes, now, [...published, ...feedbackNotes], facts);
53
+ const problem = facts ? sandboxPreflight(config, facts) : undefined;
54
+ return problem ? `${problem}\n${body}` : body;
44
55
  });
45
56
  }
46
57
  /** Startup pass shared by `kiln tick` (via `runTick`) and `kiln serve`. Idempotent. */
@@ -168,20 +179,25 @@ function observe(config, host) {
168
179
  const tag = jobTag(entry.meta.jobId);
169
180
  const ledgerResult = readAttemptLedger(config.kilnDir, entry.meta.jobId);
170
181
  const ledger = ledgerResult && ledgerResult !== "corrupt" ? ledgerResult : undefined;
171
- const picked = selectRun(runs.get(tag) ?? [], ledger);
182
+ const feedbackResult = readFeedbackLedger(config.kilnDir, entry.meta.jobId);
183
+ const feedback = feedbackResult && feedbackResult !== "corrupt" ? feedbackResult : undefined;
184
+ const repoKnown = entry.job ? Boolean(config.repos[entry.job.repo]) : false;
185
+ const repo = entry.job ? config.repos[entry.job.repo] : undefined;
186
+ const deliver = repo?.deliver ?? "local";
187
+ const deliveryRecord = entry.job && repo && deliver === "pr" ? readDelivery(config.kilnDir, entry.job.id) : undefined;
188
+ const picked = selectJobRun(runs.get(tag) ?? [], ledger, feedback, deliveryRecord?.runId);
172
189
  const run = picked.run;
173
190
  const crumb = breadcrumbs.find((candidate) => candidate.jobId === entry.meta.jobId);
174
191
  if (crumb && run && !picked.retired)
175
192
  breadcrumbsToClear.push({ path: crumb.path });
176
193
  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;
194
+ const hideBase = picked.fixInProgress && !run;
195
+ const launching = Boolean(crumb) && childAlive && (hideBase || !run || picked.retired);
196
+ const launchFailed = Boolean(crumb) && !childAlive && (hideBase || !run || picked.retired);
180
197
  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";
198
+ const deliveryRunId = picked.fixInProgress ? deliveryRecord?.runId : run?.runId;
183
199
  const delivery = entry.job && repo && deliver === "pr"
184
- ? observedDelivery(config.kilnDir, host, repo.path, entry.job.id, run?.runId)
200
+ ? observedDelivery(config.kilnDir, host, repo.path, entry.job.id, deliveryRunId)
185
201
  : undefined;
186
202
  const launchError = entry.job && !run ? readLaunchError(config.kilnDir, entry.job.id) : undefined;
187
203
  const stackError = entry.job?.stack && (entry.job.dependsOn?.length ?? 0) !== 1
@@ -237,6 +253,13 @@ function observe(config, host) {
237
253
  ...(exhausted ? { retriesExhausted: true } : {}),
238
254
  ...(picked.retired ? { runRetired: true } : {}),
239
255
  ...(ledgerResult === "corrupt" ? { ledgerError: "attempt ledger is unreadable" } : {}),
256
+ ...(entry.job ? { sandboxed: jobIsSandboxed(config, entry.job.id, entry.job.workflow) } : {}),
257
+ ...(feedback?.rounds.length ? { feedbackRounds: feedback.rounds } : {}),
258
+ ...(feedback?.exhausted ? { feedbackExhausted: true } : {}),
259
+ ...(feedback?.ambiguous ? { feedbackAmbiguous: true } : {}),
260
+ ...(feedbackResult === "corrupt" ? { feedbackLedgerError: "feedback ledger is unreadable" } : {}),
261
+ ...(picked.fixInProgress ? { fixInProgress: true } : {}),
262
+ ...(picked.fixAwaitingPublish ? { fixAwaitingPublish: true } : {}),
240
263
  ...(entry.meta.error || repoError || stackError
241
264
  ? { error: entry.meta.error ?? repoError ?? stackError }
242
265
  : {}),
@@ -252,7 +275,7 @@ function observe(config, host) {
252
275
  jobs: records,
253
276
  };
254
277
  }
255
- function apply(config, world, actions, host, notes = []) {
278
+ function apply(config, world, actions, host, notes = [], now = new Date(), feedbackNotes = [], sandboxFacts) {
256
279
  const launches = [];
257
280
  const delivers = [];
258
281
  const findings = [];
@@ -271,14 +294,33 @@ function apply(config, world, actions, host, notes = []) {
271
294
  findings.push(recorded);
272
295
  continue;
273
296
  }
297
+ if (action.type === "mark-feedback") {
298
+ markFeedback(config.kilnDir, action.jobId, action.reason);
299
+ feedbackNotes.push(`${action.reason} ${action.jobId}`);
300
+ continue;
301
+ }
302
+ if (action.type === "start-fix") {
303
+ const job = world.jobs.get(action.jobId);
304
+ if (!job) {
305
+ throw new Error(`Tick planned a fix for ${action.jobId}, which is not in the observed jobs.`);
306
+ }
307
+ const blocked = sandboxBlock(config, "fix", sandboxFacts);
308
+ if (blocked) {
309
+ feedbackNotes.push(`fix ${action.jobId} not started\n${blocked}`);
310
+ continue;
311
+ }
312
+ const report = startFix(config, world, job, host, now, sandboxFacts);
313
+ launches.push({ ...report, kind: "fix" });
314
+ continue;
315
+ }
274
316
  const job = world.jobs.get(action.jobId);
275
317
  if (!job) {
276
318
  throw new Error(`Tick planned a launch for ${action.jobId}, which is not in the observed jobs.`);
277
319
  }
278
- const report = launchJob(config, world, job, host);
320
+ const report = launchJob(config, world, job, host, sandboxFacts);
279
321
  launches.push(action.type === "retry" ? { ...report, retry: true } : report);
280
322
  }
281
- return renderTick(world.observation, actions, launches, delivers, findings, notes);
323
+ return renderTick(world.observation, actions, launches, delivers, findings, notes, feedbackNotes);
282
324
  }
283
325
  /**
284
326
  * Copy an approved investigation's markdown into `<kilnDir>/findings`.
@@ -411,11 +453,388 @@ function observedDelivery(kilnDir, host, checkout, jobId, runId) {
411
453
  ...(viewError ? { viewError } : {}),
412
454
  };
413
455
  }
414
- function launchJob(config, world, job, host) {
456
+ /**
457
+ * Attach a fix run id to an open round once its journal exists.
458
+ * A fix run that the attempt ledger has already recorded is replaced by a
459
+ * newer journal, so a retry does not stay pointed at the failed run.
460
+ */
461
+ function bindOpenFixRuns(config, world) {
462
+ const runs = scanRuns(config);
463
+ for (const job of world.observation.jobs) {
464
+ const ledger = readFeedbackLedger(config.kilnDir, job.jobId);
465
+ if (!ledger || ledger === "corrupt")
466
+ continue;
467
+ const open = openFeedbackRound(ledger);
468
+ if (!open)
469
+ continue;
470
+ const attempts = readAttemptLedger(config.kilnDir, job.jobId);
471
+ const retired = new Set();
472
+ if (attempts && attempts !== "corrupt") {
473
+ for (const id of attempts.ignoredRunIds ?? [])
474
+ retired.add(id);
475
+ for (const attempt of attempts.attempts) {
476
+ if (attempt.runId)
477
+ retired.add(attempt.runId);
478
+ }
479
+ }
480
+ if (open.fixRunId && !retired.has(open.fixRunId))
481
+ continue;
482
+ const baseRunId = readDelivery(config.kilnDir, job.jobId)?.runId;
483
+ const settled = new Set(ledger.rounds.flatMap((round) => (round.outcome === "pushed" || round.outcome === "fixed") && round.fixRunId ? [round.fixRunId] : []));
484
+ const candidates = (runs.get(jobTag(job.jobId)) ?? []).filter((run) => {
485
+ if (run.runId === baseRunId || settled.has(run.runId) || retired.has(run.runId))
486
+ return false;
487
+ return true;
488
+ });
489
+ candidates.sort((left, right) => (left.runId < right.runId ? 1 : left.runId > right.runId ? -1 : 0));
490
+ const next = candidates[0];
491
+ if (!next)
492
+ continue;
493
+ open.fixRunId = next.runId;
494
+ writeFeedbackLedger(config.kilnDir, ledger);
495
+ }
496
+ }
497
+ /**
498
+ * Fast-forward the open pull request branch to a fix run that finished ok.
499
+ * A missing branch or a history that is not a descendant is ambiguous.
500
+ * A rejected push that is not a fast-forward is ambiguous. Any other push
501
+ * error is left for the next tick. Kiln does not force-push.
502
+ */
503
+ function publishReadyFixes(config, host, world) {
504
+ const notes = [];
505
+ for (const job of world.observation.jobs) {
506
+ if (!job.fixAwaitingPublish || !job.run)
507
+ continue;
508
+ const ledger = readFeedbackLedger(config.kilnDir, job.jobId);
509
+ if (!ledger || ledger === "corrupt")
510
+ continue;
511
+ const open = openFeedbackRound(ledger);
512
+ if (!open || open.fixRunId !== job.run.runId)
513
+ continue;
514
+ const record = readDelivery(config.kilnDir, job.jobId);
515
+ const checkout = job.run.checkout;
516
+ const source = `yak/${job.run.runId}`;
517
+ if (!record?.branch) {
518
+ notes.push(`feedback ${job.jobId} failed: no pull request branch to publish`);
519
+ continue;
520
+ }
521
+ let sha;
522
+ try {
523
+ sha = host.git.revParse(checkout, source);
524
+ }
525
+ catch (error) {
526
+ markFeedback(config.kilnDir, job.jobId, "feedback-ambiguous");
527
+ notes.push(`feedback-ambiguous ${job.jobId} ${errorMessage(error)}`);
528
+ continue;
529
+ }
530
+ if (sha === open.headSha) {
531
+ open.outcome = "fixed";
532
+ writeFeedbackLedger(config.kilnDir, ledger);
533
+ notes.push(`fixed ${job.jobId} ${sha}`);
534
+ continue;
535
+ }
536
+ const ancestor = host.git.isAncestor(checkout, open.headSha, sha);
537
+ if (!ancestor) {
538
+ markFeedback(config.kilnDir, job.jobId, "feedback-ambiguous");
539
+ notes.push(`feedback-ambiguous ${job.jobId} ${source} is not based on ${open.headSha}`);
540
+ continue;
541
+ }
542
+ try {
543
+ host.git.publishBranch(checkout, source, record.branch, record.base);
544
+ }
545
+ catch (error) {
546
+ const message = errorMessage(error);
547
+ if (isNonFastForward(message)) {
548
+ markFeedback(config.kilnDir, job.jobId, "feedback-ambiguous");
549
+ notes.push(`feedback-ambiguous ${job.jobId} ${message}`);
550
+ }
551
+ else {
552
+ notes.push(`feedback ${job.jobId} failed: ${message}`);
553
+ }
554
+ continue;
555
+ }
556
+ writeDelivery(config.kilnDir, {
557
+ jobId: record.jobId,
558
+ runId: record.runId,
559
+ branch: record.branch,
560
+ base: record.base,
561
+ sha,
562
+ pushed: true,
563
+ ...(record.prNumber ? { prNumber: record.prNumber } : {}),
564
+ ...(record.prUrl ? { prUrl: record.prUrl } : {}),
565
+ });
566
+ open.outcome = "pushed";
567
+ writeFeedbackLedger(config.kilnDir, ledger);
568
+ notes.push(`pushed ${job.jobId} ${record.branch} ${sha}`);
569
+ }
570
+ return notes;
571
+ }
572
+ function isNonFastForward(message) {
573
+ return /non-fast-forward|\[rejected\]|failed to push some refs/i.test(message);
574
+ }
575
+ /**
576
+ * Read each open pull request once per tick and attach a plan.
577
+ * `readStatus` does not call this. A host with no feedback reader does not watch.
578
+ */
579
+ function watchPullRequests(config, world, host) {
580
+ const notes = [];
581
+ if (!host.feedback)
582
+ return notes;
583
+ for (const job of world.observation.jobs) {
584
+ if (job.feedbackLedgerError && job.deliver === "pr" && job.delivery?.prNumber) {
585
+ notes.push(`${job.feedbackLedgerError} ${job.jobId}`);
586
+ }
587
+ if (!shouldWatch(job) || !job.run)
588
+ continue;
589
+ const ledger = readFeedbackLedger(config.kilnDir, job.jobId);
590
+ if (ledger === "corrupt")
591
+ continue;
592
+ let snapshot;
593
+ try {
594
+ snapshot = host.feedback.read(job.run.checkout, job.delivery?.prNumber ?? 0);
595
+ }
596
+ catch (error) {
597
+ notes.push(`feedback ${job.jobId} failed: ${errorMessage(error)}`);
598
+ continue;
599
+ }
600
+ job.feedbackPlan = planFeedback({
601
+ maxFixRounds: config.maxFixRounds,
602
+ ledger,
603
+ snapshot,
604
+ });
605
+ }
606
+ return notes;
607
+ }
608
+ function shouldWatch(job) {
609
+ if (job.deliver !== "pr" || job.workflow === INVESTIGATION_WORKFLOW)
610
+ return false;
611
+ if (job.error || job.feedbackLedgerError || job.feedbackExhausted || job.feedbackAmbiguous)
612
+ return false;
613
+ if (job.fixInProgress || job.fixAwaitingPublish || job.launching)
614
+ return false;
615
+ if (!job.delivery?.prNumber)
616
+ return false;
617
+ if (job.delivery.merged || job.delivery.prState === "MERGED" || job.delivery.prState === "CLOSED")
618
+ return false;
619
+ return isLanded(job);
620
+ }
621
+ function markFeedback(kilnDir, jobId, reason) {
622
+ const existing = readFeedbackLedger(kilnDir, jobId);
623
+ const ledger = existing && existing !== "corrupt" ? existing : { jobId, rounds: [] };
624
+ if (reason === "feedback-exhausted")
625
+ ledger.exhausted = true;
626
+ else
627
+ ledger.ambiguous = true;
628
+ writeFeedbackLedger(kilnDir, ledger);
629
+ }
630
+ /**
631
+ * Open one feedback round and start the fix workflow on the pull request head.
632
+ * The round is written only after that commit resolves, so a missing sha
633
+ * retries on the next tick without consuming a round.
634
+ */
635
+ function startFix(config, world, job, host, now, sandboxFacts) {
636
+ const observed = world.observation.jobs.find((candidate) => candidate.jobId === job.id);
637
+ const plan = observed?.feedbackPlan;
638
+ const repo = config.repos[job.repo];
639
+ if (!repo || !plan || plan.action !== "fix") {
640
+ return { jobId: job.id, repo: job.repo, output: `Job ${job.id} has no fix to start.` };
641
+ }
642
+ let headSha;
643
+ try {
644
+ headSha = resolveCommit(host, repo.path, plan.headSha);
645
+ }
646
+ catch (error) {
647
+ return { jobId: job.id, repo: job.repo, output: errorMessage(error) };
648
+ }
649
+ const existing = readFeedbackLedger(config.kilnDir, job.id);
650
+ const ledger = existing && existing !== "corrupt" ? existing : { jobId: job.id, rounds: [] };
651
+ if (openFeedbackRound(ledger)) {
652
+ return { jobId: job.id, repo: job.repo, output: `Job ${job.id} already has an open fix round.` };
653
+ }
654
+ const round = ledger.rounds.length + 1;
655
+ ledger.rounds.push({
656
+ round,
657
+ headSha,
658
+ trigger: plan.trigger,
659
+ items: plan.items,
660
+ at: now.toISOString(),
661
+ });
662
+ writeFeedbackLedger(config.kilnDir, ledger);
663
+ let specText;
664
+ try {
665
+ specText = feedbackSpec(specHandedToYak(config, world, job), plan.items);
666
+ }
667
+ catch (error) {
668
+ const message = errorMessage(error);
669
+ writeLaunchError(config.kilnDir, job.id, message);
670
+ const logPath = join(config.kilnDir, "logs", `${jobBasename(job.id)}.log`);
671
+ mkdirSync(dirname(logPath), { recursive: true });
672
+ writeFileSync(logPath, `${message}\n`);
673
+ writeBreadcrumb(breadcrumbPath(config.kilnDir, job.id), { jobId: job.id, pid: 0, logPath });
674
+ return { jobId: job.id, repo: job.repo, kind: "fix", output: message };
675
+ }
676
+ const report = launchFixRun(config, job, host, repo.path, headSha, specText, sandboxFacts);
677
+ return { ...report, output: `round ${round}\n${report.output}` };
678
+ }
679
+ /** Relaunch the open fix round. Does not append a round. Failures become a dead launch so retry can see them. */
680
+ function launchOpenFix(config, world, job, host, checkout, open, sandboxFacts) {
681
+ let specText;
682
+ let headSha;
683
+ try {
684
+ clearLaunchError(config.kilnDir, job.id);
685
+ specText = feedbackSpec(specHandedToYak(config, world, job), open.items);
686
+ headSha = resolveCommit(host, checkout, open.headSha);
687
+ }
688
+ catch (error) {
689
+ const message = errorMessage(error);
690
+ writeLaunchError(config.kilnDir, job.id, message);
691
+ const logPath = join(config.kilnDir, "logs", `${jobBasename(job.id)}.log`);
692
+ mkdirSync(dirname(logPath), { recursive: true });
693
+ writeFileSync(logPath, `${message}\n`);
694
+ writeBreadcrumb(breadcrumbPath(config.kilnDir, job.id), { jobId: job.id, pid: 0, logPath });
695
+ return { jobId: job.id, repo: job.repo, kind: "fix", output: message };
696
+ }
697
+ return launchFixRun(config, job, host, checkout, headSha, specText, sandboxFacts);
698
+ }
699
+ function resolveCommit(host, checkout, sha) {
700
+ try {
701
+ return host.git.revParse(checkout, sha);
702
+ }
703
+ catch {
704
+ host.git.fetch(checkout);
705
+ return host.git.revParse(checkout, sha);
706
+ }
707
+ }
708
+ /** `workflows/fix.yaml` on `headSha`. Commits on `yak/<run-id>`. Does not push. */
709
+ function launchFixRun(config, job, host, checkout, headSha, specText, sandboxFacts) {
710
+ const sourceWorkflow = readFileSync(join(packageRoot(), "workflows", "fix.yaml"), "utf8");
711
+ const workflow = materializeWorkflow(config.kilnDir, job.id, "fix");
712
+ const repo = config.repos[job.repo];
713
+ const logPath = join(config.kilnDir, "logs", `${jobBasename(job.id)}.log`);
714
+ mkdirSync(dirname(logPath), { recursive: true });
715
+ const blocked = commitAgentSandbox(config, job.id, "fix", workflow, logPath, sandboxFacts);
716
+ if (blocked)
717
+ return { jobId: job.id, repo: job.repo, kind: "fix", output: blocked };
718
+ ensureCheckoutExcludes(checkout);
719
+ const input = workflowInputs(sourceWorkflow, {
720
+ title: job.title,
721
+ specText,
722
+ runNonce: randomUUID(),
723
+ baseRef: headSha,
724
+ });
725
+ const args = agentArgs(config, job, workflow, headSha, input);
726
+ const env = {
727
+ ...process.env,
728
+ ...(repo?.test ? { KILN_TEST: repo.test } : {}),
729
+ KILN_TASK_TITLE: job.title,
730
+ KILN_VERIFY_WRAPPER: join(packageRoot(), "dist", "truncate-output.js"),
731
+ };
732
+ return spawnLogged(config, host, {
733
+ jobId: job.id,
734
+ repo: job.repo,
735
+ kind: "fix",
736
+ cwd: checkout,
737
+ args,
738
+ env,
739
+ logPath,
740
+ });
741
+ }
742
+ /**
743
+ * Names why a sandboxed workflow cannot launch. Does not write a round or a
744
+ * breadcrumb. `undefined` when this workflow stays on the host or the checks pass.
745
+ */
746
+ function sandboxBlock(config, workflowName, sandboxFacts) {
747
+ const sandbox = resolveSandbox(config.sandbox, config.workflowSandbox[workflowName]);
748
+ if (sandbox.mode !== "docker")
749
+ return undefined;
750
+ const checked = sandboxFacts ??
751
+ probeSandboxEnvironment(process.env, [PROXY_SANDBOX_IMAGE, sandbox.image ?? AGENT_SANDBOX_IMAGE]);
752
+ return sandboxPreflightFor(sandbox, config.adapter, checked);
753
+ }
754
+ /**
755
+ * Inject `sandbox: docker` into a materialized workflow and refuse the launch
756
+ * when preflight fails. The refusal is a launch error with no yak process.
757
+ */
758
+ function commitAgentSandbox(config, jobId, workflowName, workflowPath, logPath, sandboxFacts) {
759
+ const sandbox = resolveSandbox(config.sandbox, config.workflowSandbox[workflowName]);
760
+ if (sandbox.mode !== "docker")
761
+ return undefined;
762
+ writeFileSync(workflowPath, injectAgentSandbox(readFileSync(workflowPath, "utf8"), sandbox));
763
+ const problem = sandboxBlock(config, workflowName, sandboxFacts);
764
+ if (!problem)
765
+ return undefined;
766
+ writeLaunchError(config.kilnDir, jobId, problem);
767
+ writeFileSync(logPath, `${problem}\n`);
768
+ return problem;
769
+ }
770
+ function agentArgs(config, job, workflow, headSha, input) {
771
+ const args = [
772
+ yakCliPath(),
773
+ "run",
774
+ workflow,
775
+ "--adapter",
776
+ config.adapter,
777
+ "--isolation",
778
+ "worktree",
779
+ "--from",
780
+ headSha,
781
+ "--tag",
782
+ jobTag(job.id),
783
+ ];
784
+ for (const [key, value] of Object.entries(input)) {
785
+ args.push("--input", `${key}=${value}`);
786
+ }
787
+ return args;
788
+ }
789
+ function spawnLogged(config, host, spec) {
790
+ const crumbPath = breadcrumbPath(config.kilnDir, spec.jobId);
791
+ writeBreadcrumb(crumbPath, { jobId: spec.jobId, pid: 0, logPath: spec.logPath });
792
+ const logFd = openSync(spec.logPath, "a");
793
+ try {
794
+ const started = spawnAgent(host, { cwd: spec.cwd, args: spec.args, env: spec.env, logFd });
795
+ const pid = started.pid ?? 0;
796
+ writeBreadcrumb(crumbPath, { jobId: spec.jobId, pid, logPath: spec.logPath });
797
+ return {
798
+ jobId: spec.jobId,
799
+ repo: spec.repo,
800
+ ...(spec.kind ? { kind: spec.kind } : {}),
801
+ output: `pid ${pid}\nlog ${spec.logPath}`,
802
+ };
803
+ }
804
+ catch (error) {
805
+ writeFileSync(spec.logPath, `${errorMessage(error)}\n`);
806
+ return {
807
+ jobId: spec.jobId,
808
+ repo: spec.repo,
809
+ ...(spec.kind ? { kind: spec.kind } : {}),
810
+ output: `pid 0\nlog ${spec.logPath}`,
811
+ };
812
+ }
813
+ finally {
814
+ closeSync(logFd);
815
+ }
816
+ }
817
+ function spawnAgent(host, spec) {
818
+ if (host.startAgent)
819
+ return host.startAgent(spec);
820
+ const child = spawn(process.execPath, spec.args, {
821
+ cwd: spec.cwd,
822
+ detached: true,
823
+ stdio: ["ignore", spec.logFd, spec.logFd],
824
+ env: spec.env,
825
+ });
826
+ child.unref();
827
+ return { pid: child.pid ?? 0 };
828
+ }
829
+ function launchJob(config, world, job, host, sandboxFacts) {
415
830
  const repo = config.repos[job.repo];
416
831
  if (!repo) {
417
832
  throw new Error(`Repo ${job.repo} is not registered. Run kiln repo add ${job.repo} <path>.`);
418
833
  }
834
+ const feedback = readFeedbackLedger(config.kilnDir, job.id);
835
+ const open = feedback && feedback !== "corrupt" ? openFeedbackRound(feedback) : undefined;
836
+ if (open)
837
+ return launchOpenFix(config, world, job, host, repo.path, open, sandboxFacts);
419
838
  const sourceWorkflow = readFileSync(join(packageRoot(), "workflows", `${job.workflow}.yaml`), "utf8");
420
839
  const testCommand = repo.test;
421
840
  if (workflowNeedsTestCommand(sourceWorkflow) && !testCommand?.trim()) {
@@ -424,12 +843,14 @@ function launchJob(config, world, job, host) {
424
843
  const workflow = materializeWorkflow(config.kilnDir, job.id, job.workflow);
425
844
  const logPath = join(config.kilnDir, "logs", `${jobBasename(job.id)}.log`);
426
845
  mkdirSync(dirname(logPath), { recursive: true });
427
- const crumbPath = breadcrumbPath(config.kilnDir, job.id);
428
846
  ensureCheckoutExcludes(repo.path);
429
847
  let baseSha;
430
848
  let specText;
431
849
  try {
432
850
  clearLaunchError(config.kilnDir, job.id);
851
+ const blocked = commitAgentSandbox(config, job.id, job.workflow, workflow, logPath, sandboxFacts);
852
+ if (blocked)
853
+ return { jobId: job.id, repo: job.repo, output: blocked };
433
854
  assertStackTarget(world, job);
434
855
  baseSha = resolveLaunchBase({
435
856
  checkout: repo.path,
@@ -453,49 +874,14 @@ function launchJob(config, world, job, host) {
453
874
  runNonce: randomUUID(),
454
875
  baseRef: baseSha,
455
876
  });
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
- }
877
+ const args = agentArgs(config, job, workflow, baseSha, input);
472
878
  const env = {
473
879
  ...process.env,
474
880
  KILN_TEST: testCommand,
475
881
  KILN_TASK_TITLE: job.title,
476
882
  KILN_VERIFY_WRAPPER: join(packageRoot(), "dist", "truncate-output.js"),
477
883
  };
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
- }
884
+ return spawnLogged(config, host, { jobId: job.id, repo: job.repo, cwd: repo.path, args, env, logPath });
499
885
  }
500
886
  function loadJobs(config) {
501
887
  if (!existsSync(config.jobsDir))
@@ -547,6 +933,7 @@ function scanRuns(config) {
547
933
  const finished = [...events].reverse().find((event) => event.t === "run.finished");
548
934
  const stepPlan = readRunStepPlan(runDir);
549
935
  const stepEvents = stepEventsFrom(events);
936
+ const sandboxError = sandboxFailureDetail(stepEvents);
550
937
  const pid = livePid(events);
551
938
  const activityMs = eventActivityMs(events) ?? fileMtimeMs(join(runDir, "journal.jsonl"));
552
939
  const finishedReason = finished && typeof finished.reason === "string" ? finished.reason : undefined;
@@ -564,6 +951,7 @@ function scanRuns(config) {
564
951
  ...(pid !== undefined ? { pid } : {}),
565
952
  ...(activityMs !== undefined ? { activityMs } : {}),
566
953
  ...(finishedReason ? { finishedReason } : {}),
954
+ ...(sandboxError ? { sandboxError } : {}),
567
955
  };
568
956
  const list = byTag.get(started.tag) ?? [];
569
957
  list.push(run);
@@ -579,11 +967,12 @@ function stepEventsFrom(events) {
579
967
  continue;
580
968
  if (!event.stepId)
581
969
  continue;
582
- const detail = event.t === "step.failed" ? formatFailure(event.failure) : undefined;
970
+ const failure = event.t === "step.failed" ? formatFailure(event.failure) : undefined;
583
971
  found.push({
584
972
  t: event.t,
585
973
  stepId: event.stepId,
586
- ...(detail ? { detail } : {}),
974
+ ...(failure?.detail ? { detail: failure.detail } : {}),
975
+ ...(failure?.reason ? { failureReason: failure.reason } : {}),
587
976
  });
588
977
  }
589
978
  return found;
@@ -594,7 +983,12 @@ function formatFailure(failure) {
594
983
  const detail = typeof failure.detail === "string" ? failure.detail.trim() : "";
595
984
  const reason = typeof failure.reason === "string" ? failure.reason.trim() : "";
596
985
  const text = detail && reason ? `${detail} (${reason})` : detail || reason;
597
- return text || undefined;
986
+ if (!text && !reason)
987
+ return undefined;
988
+ return {
989
+ ...(text ? { detail: text } : {}),
990
+ ...(reason ? { reason } : {}),
991
+ };
598
992
  }
599
993
  function readRunStepPlan(runDir) {
600
994
  const file = join(runDir, "workflow.json");
@@ -694,6 +1088,42 @@ function readLogTail(logPath) {
694
1088
  * even when its id does not sort after the failed one. Otherwise keep the
695
1089
  * latest id, which is what a single-run job has always shown.
696
1090
  */
1091
+ function selectJobRun(runs, ledger, feedback, baseRunId) {
1092
+ const settled = new Set();
1093
+ for (const round of feedback?.rounds ?? []) {
1094
+ if ((round.outcome === "pushed" || round.outcome === "fixed") && round.fixRunId)
1095
+ settled.add(round.fixRunId);
1096
+ }
1097
+ const ignored = new Set(ledger?.ignoredRunIds ?? []);
1098
+ const failed = new Set((ledger?.attempts ?? []).flatMap((attempt) => (attempt.runId ? [attempt.runId] : [])));
1099
+ const open = openFeedbackRound(feedback);
1100
+ if (open) {
1101
+ const bound = open.fixRunId && !ignored.has(open.fixRunId) && !failed.has(open.fixRunId) ? open.fixRunId : undefined;
1102
+ const fixRuns = runs.filter((run) => {
1103
+ if (settled.has(run.runId) || ignored.has(run.runId))
1104
+ return false;
1105
+ if (bound)
1106
+ return run.runId === bound;
1107
+ if (failed.has(run.runId))
1108
+ return false;
1109
+ return baseRunId ? run.runId !== baseRunId : true;
1110
+ });
1111
+ fixRuns.sort((left, right) => (left.runId < right.runId ? 1 : left.runId > right.runId ? -1 : 0));
1112
+ const fix = fixRuns[0];
1113
+ if (fix) {
1114
+ const retired = failed.has(fix.runId);
1115
+ return {
1116
+ run: fix,
1117
+ retired,
1118
+ fixInProgress: true,
1119
+ fixAwaitingPublish: fix.status === "ok" && !retired,
1120
+ };
1121
+ }
1122
+ return { retired: false, fixInProgress: true, fixAwaitingPublish: false };
1123
+ }
1124
+ const picked = selectRun(runs.filter((run) => !settled.has(run.runId)), ledger);
1125
+ return { ...picked, fixInProgress: false, fixAwaitingPublish: false };
1126
+ }
697
1127
  function selectRun(runs, ledger) {
698
1128
  const ignored = new Set(ledger?.ignoredRunIds ?? []);
699
1129
  const failed = new Set((ledger?.attempts ?? []).flatMap((attempt) => (attempt.runId ? [attempt.runId] : [])));