@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/README.md +61 -5
- package/dist/attention.js +28 -2
- package/dist/cli.js +30 -0
- package/dist/config.js +14 -1
- package/dist/driver.js +484 -54
- package/dist/feedback.js +610 -0
- package/dist/host.js +26 -1
- package/dist/reconcile.js +31 -3
- package/dist/reliability.js +16 -0
- package/dist/report.js +76 -9
- package/dist/sandbox.js +363 -0
- package/dist/schemas.js +26 -0
- package/dist/serve-page.js +14 -1
- package/dist/serve.js +7 -1
- package/dist/workflow.js +2 -0
- package/package.json +1 -1
- package/prompts/kiln/fix.md +17 -0
- package/workflows/feature.yaml +5 -0
- package/workflows/fix.yaml +48 -0
- package/workflows/investigation.yaml +3 -0
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
|
-
|
|
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
|
|
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
|
|
178
|
-
const
|
|
179
|
-
const
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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] : [])));
|