@lchase/kiln 0.8.0 → 0.10.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 +6 -4
- package/assets/serve.css +13 -0
- package/assets/serve.js +49 -3
- package/dist/attention.js +13 -4
- package/dist/cli.js +112 -8
- package/dist/config.js +25 -1
- package/dist/delivery.js +18 -0
- package/dist/driver.js +213 -14
- package/dist/events.js +393 -0
- package/dist/evidence.js +140 -0
- package/dist/failure.js +140 -0
- package/dist/fix-commit.js +73 -0
- package/dist/jobs.js +12 -0
- package/dist/ledger.js +152 -0
- package/dist/project.js +394 -0
- package/dist/rails.js +164 -0
- package/dist/reconcile.js +29 -3
- package/dist/redact.js +63 -0
- package/dist/reliability.js +75 -26
- package/dist/report.js +19 -5
- package/dist/schemas.js +35 -5
- package/dist/serve-page.js +21 -1
- package/dist/serve.js +86 -4
- package/dist/settings.js +9 -1
- package/package.json +2 -2
- package/workflows/fix.yaml +1 -1
package/dist/driver.js
CHANGED
|
@@ -4,14 +4,20 @@ import { closeSync, existsSync, linkSync, mkdirSync, openSync, readdirSync, read
|
|
|
4
4
|
import { basename, dirname, isAbsolute, join } from "node:path";
|
|
5
5
|
import { claudeCredentialPreflight } from "./auth.js";
|
|
6
6
|
import { buildAttention } from "./attention.js";
|
|
7
|
+
import { attemptsFor, fixRoundsFor } from "./config.js";
|
|
7
8
|
import { clearLaunchError, deliverJob, plannedRemoteBranch, readDelivery, readLaunchError, resolveLaunchBase, writeDelivery, writeLaunchError, isMerged, } from "./delivery.js";
|
|
8
9
|
import { feedbackSpec, openFeedbackRound, planFeedback, readFeedbackLedger, writeFeedbackLedger, } from "./feedback.js";
|
|
10
|
+
import { appendEvent, readEvents } from "./events.js";
|
|
11
|
+
import { costFromJournal } from "./failure.js";
|
|
12
|
+
import { ensureFixTrailers } from "./fix-commit.js";
|
|
13
|
+
import { holdDetail, pausedFrom, recordDailyCap, reviseNotesSinceRun, spendSnapshot, stopReasonFromEvents, withReviseNotes, } from "./rails.js";
|
|
9
14
|
import { appendInvestigationFindings, existingFindingsPath, findingsFilePath, INVESTIGATION_WORKFLOW, readFindingsMarkdown, writeFindings, } from "./findings.js";
|
|
10
15
|
import { defaultHost } from "./host.js";
|
|
11
16
|
import { readJobSpec, resolveJobSpec } from "./jobs.js";
|
|
12
17
|
import { jobBasename, packageRoot } from "./paths.js";
|
|
13
18
|
import { decide, isLanded, jobState, } from "./reconcile.js";
|
|
14
19
|
import { fileMtimeMs, isPidAlive, readAttemptLedger, recoverRuns, resetAttemptLedger, } from "./reliability.js";
|
|
20
|
+
import { projectRunJournal, promptsHash } from "./project.js";
|
|
15
21
|
import { gateAnswerCommand, renderStatus, renderTick, statusView, yakCliPath, sandboxFailureDetail, } from "./report.js";
|
|
16
22
|
import { AGENT_SANDBOX_IMAGE, injectAgentSandbox, jobIsSandboxed, probeSandboxEnvironment, PROXY_SANDBOX_IMAGE, requiredSandboxImages, resolveSandbox, sandboxIsEnabled, sandboxPreflight, sandboxPreflightFor, } from "./sandbox.js";
|
|
17
23
|
import { JobSchema } from "./schemas.js";
|
|
@@ -40,16 +46,19 @@ export function runTick(config, host = defaultHost(), options = {}) {
|
|
|
40
46
|
const startup = options.startup ?? true;
|
|
41
47
|
const now = options.now ?? new Date();
|
|
42
48
|
return withTickLock(config.kilnDir, () => {
|
|
43
|
-
const notes = recoverRuns(config, observe(config, host).observation.jobs, { startup, now });
|
|
49
|
+
const notes = recoverRuns(config, observe(config, host, now).observation.jobs, { startup, now });
|
|
44
50
|
const facts = sandboxIsEnabled(config)
|
|
45
51
|
? (options.sandboxFacts ?? probeSandboxEnvironment(process.env, requiredSandboxImages(config)))
|
|
46
52
|
: undefined;
|
|
47
|
-
const midway = observe(config, host);
|
|
53
|
+
const midway = observe(config, host, now);
|
|
48
54
|
bindOpenFixRuns(config, midway);
|
|
49
55
|
const published = publishReadyFixes(config, host, midway);
|
|
50
|
-
const world = observe(config, host);
|
|
56
|
+
const world = observe(config, host, now);
|
|
51
57
|
const feedbackNotes = watchPullRequests(config, world, host);
|
|
52
58
|
const actions = decide(world.observation, now);
|
|
59
|
+
if (world.observation.spendBlocked) {
|
|
60
|
+
recordDailyCap(config.kilnDir, spendSnapshot(readEvents(config.kilnDir, { warn: () => { } }), config.dailyDollarCap, now), now);
|
|
61
|
+
}
|
|
53
62
|
const body = apply(config, world, actions, host, notes, now, [...published, ...feedbackNotes], facts);
|
|
54
63
|
const heads = [
|
|
55
64
|
claudeCredentialPreflight(process.env, config.adapter),
|
|
@@ -61,7 +70,7 @@ export function runTick(config, host = defaultHost(), options = {}) {
|
|
|
61
70
|
/** Startup pass shared by `kiln tick` (via `runTick`) and `kiln serve`. Idempotent. */
|
|
62
71
|
export function startupRecovery(config, host = defaultHost(), now = new Date()) {
|
|
63
72
|
withTickLock(config.kilnDir, () => {
|
|
64
|
-
recoverRuns(config, observe(config, host).observation.jobs, { startup: true, now });
|
|
73
|
+
recoverRuns(config, observe(config, host, now).observation.jobs, { startup: true, now });
|
|
65
74
|
});
|
|
66
75
|
}
|
|
67
76
|
export function answerJob(config, jobId, choice, host = defaultHost()) {
|
|
@@ -87,6 +96,20 @@ export function answerJob(config, jobId, choice, host = defaultHost()) {
|
|
|
87
96
|
if (result.status !== 0) {
|
|
88
97
|
throw new Error(output.trim() || `Could not ${choice} job ${jobId}.`);
|
|
89
98
|
}
|
|
99
|
+
const noted = gateEdit(job.run.checkout, job.run.runId, gate.stepId);
|
|
100
|
+
appendEvent(config.kilnDir, {
|
|
101
|
+
job_id: jobId,
|
|
102
|
+
run_id: job.run.runId,
|
|
103
|
+
event: "gate.resolved",
|
|
104
|
+
step: gate.stepId,
|
|
105
|
+
properties: {
|
|
106
|
+
decision: choice,
|
|
107
|
+
by: "human",
|
|
108
|
+
repo: job.repo,
|
|
109
|
+
...noted,
|
|
110
|
+
once: `gate.resolved:${gate.stepId}`,
|
|
111
|
+
},
|
|
112
|
+
});
|
|
90
113
|
if (choice === "approve") {
|
|
91
114
|
recordInvestigationFindings(config, observe(config, host), jobId);
|
|
92
115
|
}
|
|
@@ -110,8 +133,8 @@ export function retryJob(config, jobId, host = defaultHost()) {
|
|
|
110
133
|
if (observed.error)
|
|
111
134
|
throw new Error(`Job ${jobId} is invalid: ${observed.error}`);
|
|
112
135
|
const state = jobState(observed, world.observation);
|
|
113
|
-
if (state
|
|
114
|
-
throw new Error(`Job ${jobId} is ${state}, not failed or
|
|
136
|
+
if (!canManualRetry(state, observed)) {
|
|
137
|
+
throw new Error(`Job ${jobId} is ${state}, not failed, dead, or stopped for a person.`);
|
|
115
138
|
}
|
|
116
139
|
const job = world.jobs.get(jobId);
|
|
117
140
|
if (!job)
|
|
@@ -120,6 +143,11 @@ export function retryJob(config, jobId, host = defaultHost()) {
|
|
|
120
143
|
if (crumb && pidAlive(crumb.pid)) {
|
|
121
144
|
throw new Error(`Job ${jobId} is already launching.`);
|
|
122
145
|
}
|
|
146
|
+
appendEvent(config.kilnDir, {
|
|
147
|
+
job_id: jobId,
|
|
148
|
+
event: "job.retried",
|
|
149
|
+
properties: { by: "human", repo: job.repo },
|
|
150
|
+
});
|
|
123
151
|
const cleared = observed.attemptCount ?? 0;
|
|
124
152
|
resetAttemptLedger(config.kilnDir, jobId, observed.run?.runId);
|
|
125
153
|
rmSync(breadcrumbPath(config.kilnDir, jobId), { force: true });
|
|
@@ -133,7 +161,7 @@ export function retryJob(config, jobId, host = defaultHost()) {
|
|
|
133
161
|
const detail = report.detail.endsWith("\n") || report.detail.length === 0 ? report.detail : `${report.detail}\n`;
|
|
134
162
|
return `retry ${jobId} ${job.repo}\n${clearedLine}${detail}`;
|
|
135
163
|
}
|
|
136
|
-
if (state === "dead" && observed.run) {
|
|
164
|
+
if ((state === "dead" || observed.stopReason) && observed.run) {
|
|
137
165
|
try {
|
|
138
166
|
spawnSync(process.execPath, [yakCliPath(), "cancel", observed.run.runId], {
|
|
139
167
|
cwd: observed.run.checkout,
|
|
@@ -153,6 +181,64 @@ export function retryJob(config, jobId, host = defaultHost()) {
|
|
|
153
181
|
return `retry ${report.jobId} ${report.repo}\n${clearedLine}${output}`;
|
|
154
182
|
});
|
|
155
183
|
}
|
|
184
|
+
function canManualRetry(state, job) {
|
|
185
|
+
if (state === "failed" || state === "dead")
|
|
186
|
+
return true;
|
|
187
|
+
return state === "needs-human" && Boolean(job.stopReason) && (job.run?.gates.length ?? 0) === 0;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Send a gated job back for another round. The note is stored on a
|
|
191
|
+
* `gate.resolved` event (redacted, otherwise verbatim) and appended to the
|
|
192
|
+
* spec the next launch passes into the prompt.
|
|
193
|
+
*/
|
|
194
|
+
export function reviseJob(config, jobId, note, host = defaultHost()) {
|
|
195
|
+
const trimmed = note.trim();
|
|
196
|
+
if (!trimmed)
|
|
197
|
+
throw new Error("A revise note is required.");
|
|
198
|
+
if (trimmed.length > 8_000)
|
|
199
|
+
throw new Error("A revise note must be 8000 characters or fewer.");
|
|
200
|
+
return withTickLock(config.kilnDir, () => {
|
|
201
|
+
const world = observe(config, host);
|
|
202
|
+
const observed = world.observation.jobs.find((candidate) => candidate.jobId === jobId);
|
|
203
|
+
if (!observed)
|
|
204
|
+
throw new Error(`No job ${jobId}.`);
|
|
205
|
+
if (observed.error)
|
|
206
|
+
throw new Error(`Job ${jobId} is invalid: ${observed.error}`);
|
|
207
|
+
const state = jobState(observed, world.observation);
|
|
208
|
+
const gate = observed.run?.gates[0];
|
|
209
|
+
if (state !== "needs-human" || !observed.run || !gate) {
|
|
210
|
+
throw new Error(`Job ${jobId} is ${state}, not waiting on a gate.`);
|
|
211
|
+
}
|
|
212
|
+
const job = world.jobs.get(jobId);
|
|
213
|
+
if (!job)
|
|
214
|
+
throw new Error(`No job ${jobId}.`);
|
|
215
|
+
appendEvent(config.kilnDir, {
|
|
216
|
+
job_id: jobId,
|
|
217
|
+
run_id: observed.run.runId,
|
|
218
|
+
event: "gate.resolved",
|
|
219
|
+
step: gate.stepId,
|
|
220
|
+
properties: {
|
|
221
|
+
decision: "revise",
|
|
222
|
+
by: "human",
|
|
223
|
+
repo: job.repo,
|
|
224
|
+
note: trimmed,
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
resetAttemptLedger(config.kilnDir, jobId, observed.run.runId);
|
|
228
|
+
rmSync(breadcrumbPath(config.kilnDir, jobId), { force: true });
|
|
229
|
+
clearLaunchError(config.kilnDir, jobId);
|
|
230
|
+
const events = readEvents(config.kilnDir, { warn: () => { } });
|
|
231
|
+
const spend = spendSnapshot(events, config.dailyDollarCap);
|
|
232
|
+
const paused = pausedFrom(events);
|
|
233
|
+
const hold = holdDetail(paused, spend);
|
|
234
|
+
if (hold)
|
|
235
|
+
return `revised ${jobId}\n${hold}\n`;
|
|
236
|
+
const again = observe(config, host);
|
|
237
|
+
const report = launchJob(config, again, job, host);
|
|
238
|
+
const output = report.output.endsWith("\n") || report.output.length === 0 ? report.output : `${report.output}\n`;
|
|
239
|
+
return `revised ${jobId} ${report.repo}\n${output}`;
|
|
240
|
+
});
|
|
241
|
+
}
|
|
156
242
|
function shouldRedeliver(job) {
|
|
157
243
|
if (!isLanded(job))
|
|
158
244
|
return false;
|
|
@@ -164,9 +250,13 @@ function gateForChoice(gates, choice) {
|
|
|
164
250
|
return (gates.find((gate) => gate.choices?.includes(choice)) ??
|
|
165
251
|
gates.find((gate) => !gate.choices?.length));
|
|
166
252
|
}
|
|
167
|
-
function observe(config, host) {
|
|
253
|
+
function observe(config, host, now = new Date()) {
|
|
168
254
|
const loaded = loadJobs(config);
|
|
169
255
|
const runs = scanRuns(config);
|
|
256
|
+
const events = readEvents(config.kilnDir, { warn: () => { } });
|
|
257
|
+
const paused = pausedFrom(events);
|
|
258
|
+
const spend = spendSnapshot(events, config.dailyDollarCap, now);
|
|
259
|
+
const waiting = holdDetail(paused, spend);
|
|
170
260
|
const breadcrumbs = readBreadcrumbs(config.kilnDir);
|
|
171
261
|
const jobs = [];
|
|
172
262
|
const records = new Map();
|
|
@@ -213,10 +303,13 @@ function observe(config, host) {
|
|
|
213
303
|
const launchActivityMs = later(crumb ? fileMtimeMs(crumb.path) : undefined, logPath ? fileMtimeMs(logPath) : undefined);
|
|
214
304
|
const attemptCount = ledger?.attempts.length ?? 0;
|
|
215
305
|
const lastFailure = ledger?.attempts.at(-1);
|
|
216
|
-
const
|
|
306
|
+
const attemptCap = attemptsFor(config, entry.job?.repo ?? "");
|
|
307
|
+
const exhausted = attemptCount >= attemptCap &&
|
|
217
308
|
attemptCount > 0 &&
|
|
218
309
|
!launching &&
|
|
219
310
|
(picked.retired || !run || run.status === "dead" || run.status === "failed");
|
|
311
|
+
const eventStop = entry.job ? stopReasonFromEvents(events, entry.job.id) : undefined;
|
|
312
|
+
const stopReason = eventStop ?? (exhausted ? "attempt-cap" : undefined);
|
|
220
313
|
const specPath = entry.job ? resolveJobSpec(config.kilnDir, entry.job.spec) : undefined;
|
|
221
314
|
const findingsPath = entry.job?.workflow === INVESTIGATION_WORKFLOW
|
|
222
315
|
? existingFindingsPath(config.kilnDir, entry.job.id)
|
|
@@ -255,6 +348,8 @@ function observe(config, host) {
|
|
|
255
348
|
...(lastFailure ? { lastFailureReason: lastFailure.reason } : {}),
|
|
256
349
|
...(ledger?.nextRetryAt && !exhausted ? { nextRetryAt: ledger.nextRetryAt } : {}),
|
|
257
350
|
...(exhausted ? { retriesExhausted: true } : {}),
|
|
351
|
+
...(stopReason ? { stopReason } : {}),
|
|
352
|
+
attemptCap,
|
|
258
353
|
...(picked.retired ? { runRetired: true } : {}),
|
|
259
354
|
...(ledgerResult === "corrupt" ? { ledgerError: "attempt ledger is unreadable" } : {}),
|
|
260
355
|
...(entry.job ? { sandboxed: jobIsSandboxed(config, entry.job.id, entry.job.workflow) } : {}),
|
|
@@ -275,6 +370,9 @@ function observe(config, host) {
|
|
|
275
370
|
maxAttempts: config.maxAttempts,
|
|
276
371
|
jobs,
|
|
277
372
|
breadcrumbsToClear,
|
|
373
|
+
...(paused ? { paused: true } : {}),
|
|
374
|
+
...(spend.blocked ? { spendBlocked: true } : {}),
|
|
375
|
+
...(waiting ? { holdDetail: waiting } : {}),
|
|
278
376
|
},
|
|
279
377
|
jobs: records,
|
|
280
378
|
};
|
|
@@ -417,7 +515,8 @@ function specHandedToYak(config, world, job) {
|
|
|
417
515
|
}
|
|
418
516
|
sections.push({ title: dep.title, markdown: readFileSync(path, "utf8") });
|
|
419
517
|
}
|
|
420
|
-
|
|
518
|
+
const noted = withReviseNotes(appendInvestigationFindings(base, sections), reviseNotesSinceRun(readEvents(config.kilnDir, { warn: () => { } }), job.id));
|
|
519
|
+
return noted;
|
|
421
520
|
}
|
|
422
521
|
/** Remote name the stacked pull request targets. Falls back to the local yak ref. */
|
|
423
522
|
function upstreamBranch(world, job) {
|
|
@@ -529,6 +628,15 @@ function publishReadyFixes(config, host, world) {
|
|
|
529
628
|
const record = readDelivery(config.kilnDir, job.jobId);
|
|
530
629
|
const checkout = job.run.checkout;
|
|
531
630
|
const source = `yak/${job.run.runId}`;
|
|
631
|
+
const worktree = join(checkout, ".yak", "worktrees", job.run.runId);
|
|
632
|
+
if (existsSync(worktree)) {
|
|
633
|
+
ensureFixTrailers(worktree, {
|
|
634
|
+
jobId: job.jobId,
|
|
635
|
+
round: open.round,
|
|
636
|
+
promptsHash: hashForRun(config.kilnDir, job.jobId, job.run.runId),
|
|
637
|
+
title: job.title,
|
|
638
|
+
}, open.headSha);
|
|
639
|
+
}
|
|
532
640
|
if (!record?.branch) {
|
|
533
641
|
notes.push(`feedback ${job.jobId} failed: no pull request branch to publish`);
|
|
534
642
|
continue;
|
|
@@ -545,6 +653,7 @@ function publishReadyFixes(config, host, world) {
|
|
|
545
653
|
if (sha === open.headSha) {
|
|
546
654
|
open.outcome = "fixed";
|
|
547
655
|
writeFeedbackLedger(config.kilnDir, ledger);
|
|
656
|
+
recordFeedbackOutcome(config.kilnDir, job.jobId, job.run.runId, open, "fixed");
|
|
548
657
|
notes.push(`fixed ${job.jobId} ${sha}`);
|
|
549
658
|
continue;
|
|
550
659
|
}
|
|
@@ -580,13 +689,62 @@ function publishReadyFixes(config, host, world) {
|
|
|
580
689
|
});
|
|
581
690
|
open.outcome = "pushed";
|
|
582
691
|
writeFeedbackLedger(config.kilnDir, ledger);
|
|
692
|
+
recordFeedbackOutcome(config.kilnDir, job.jobId, job.run.runId, open, "pushed");
|
|
583
693
|
notes.push(`pushed ${job.jobId} ${record.branch} ${sha}`);
|
|
584
694
|
}
|
|
585
695
|
return notes;
|
|
586
696
|
}
|
|
697
|
+
function hashForRun(kilnDir, jobId, runId) {
|
|
698
|
+
for (const event of readEvents(kilnDir, { warn: () => { } })) {
|
|
699
|
+
if (event.job_id === jobId && event.run_id === runId && event.event === "run.started") {
|
|
700
|
+
return event.properties.prompts_hash;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
return promptsHash("fix", kilnDir, jobId);
|
|
704
|
+
}
|
|
587
705
|
function isNonFastForward(message) {
|
|
588
706
|
return /non-fast-forward|\[rejected\]|failed to push some refs/i.test(message);
|
|
589
707
|
}
|
|
708
|
+
function recordFeedbackOutcome(kilnDir, jobId, runId, round, outcome) {
|
|
709
|
+
appendEvent(kilnDir, {
|
|
710
|
+
job_id: jobId,
|
|
711
|
+
run_id: runId,
|
|
712
|
+
event: "pr.feedback",
|
|
713
|
+
properties: {
|
|
714
|
+
round: round.round,
|
|
715
|
+
trigger: round.trigger,
|
|
716
|
+
outcome,
|
|
717
|
+
items: round.items.length,
|
|
718
|
+
once: `pr.feedback:${round.round}:${outcome}`,
|
|
719
|
+
},
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
/** An edit note the human left on the gate answer, when the answer file has one. */
|
|
723
|
+
function gateEdit(checkout, runId, stepId) {
|
|
724
|
+
const paths = [
|
|
725
|
+
join(checkout, ".runs", runId, "pending", `${stepId}.answer.json`),
|
|
726
|
+
join(checkout, ".runs", runId, "artifacts", "approval.json"),
|
|
727
|
+
];
|
|
728
|
+
for (const path of paths) {
|
|
729
|
+
if (!existsSync(path))
|
|
730
|
+
continue;
|
|
731
|
+
try {
|
|
732
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
733
|
+
const note = typeof parsed.note === "string" ? parsed.note.trim() : "";
|
|
734
|
+
const edit = typeof parsed.edit === "string" ? parsed.edit.trim() : "";
|
|
735
|
+
if (!note && !edit)
|
|
736
|
+
continue;
|
|
737
|
+
return {
|
|
738
|
+
...(note ? { note: note.slice(0, 240) } : {}),
|
|
739
|
+
...(edit ? { edit: edit.slice(0, 240) } : {}),
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
catch {
|
|
743
|
+
// An unreadable answer is not an edit note.
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
return {};
|
|
747
|
+
}
|
|
590
748
|
/**
|
|
591
749
|
* Read each open pull request once per tick and attach a plan.
|
|
592
750
|
* `readStatus` does not call this. A host with no feedback reader does not watch.
|
|
@@ -613,7 +771,7 @@ function watchPullRequests(config, world, host) {
|
|
|
613
771
|
continue;
|
|
614
772
|
}
|
|
615
773
|
job.feedbackPlan = planFeedback({
|
|
616
|
-
maxFixRounds: config.
|
|
774
|
+
maxFixRounds: fixRoundsFor(config, job.repo),
|
|
617
775
|
ledger,
|
|
618
776
|
snapshot,
|
|
619
777
|
});
|
|
@@ -641,6 +799,11 @@ function markFeedback(kilnDir, jobId, reason) {
|
|
|
641
799
|
else
|
|
642
800
|
ledger.ambiguous = true;
|
|
643
801
|
writeFeedbackLedger(kilnDir, ledger);
|
|
802
|
+
appendEvent(kilnDir, {
|
|
803
|
+
job_id: jobId,
|
|
804
|
+
event: "needs-human",
|
|
805
|
+
properties: { reason, once: `needs-human:${reason}` },
|
|
806
|
+
});
|
|
644
807
|
}
|
|
645
808
|
/**
|
|
646
809
|
* Open one feedback round and start the fix workflow on the pull request head.
|
|
@@ -678,6 +841,16 @@ function startFix(config, world, job, host, now, sandboxFacts) {
|
|
|
678
841
|
at: now.toISOString(),
|
|
679
842
|
});
|
|
680
843
|
writeFeedbackLedger(config.kilnDir, ledger);
|
|
844
|
+
appendEvent(config.kilnDir, {
|
|
845
|
+
job_id: job.id,
|
|
846
|
+
event: "pr.feedback",
|
|
847
|
+
properties: {
|
|
848
|
+
round,
|
|
849
|
+
trigger: plan.trigger,
|
|
850
|
+
items: plan.items.length,
|
|
851
|
+
once: `pr.feedback:${round}:open`,
|
|
852
|
+
},
|
|
853
|
+
});
|
|
681
854
|
let specText;
|
|
682
855
|
try {
|
|
683
856
|
specText = feedbackSpec(specHandedToYak(config, world, job), plan.items);
|
|
@@ -691,7 +864,7 @@ function startFix(config, world, job, host, now, sandboxFacts) {
|
|
|
691
864
|
writeBreadcrumb(breadcrumbPath(config.kilnDir, job.id), { jobId: job.id, pid: 0, logPath });
|
|
692
865
|
return { jobId: job.id, repo: job.repo, kind: "fix", output: message };
|
|
693
866
|
}
|
|
694
|
-
const report = launchFixRun(config, job, host, repo.path, headSha, specText, sandboxFacts);
|
|
867
|
+
const report = launchFixRun(config, job, host, repo.path, headSha, specText, round, sandboxFacts);
|
|
695
868
|
return { ...report, output: `round ${round}\n${report.output}` };
|
|
696
869
|
}
|
|
697
870
|
/** Relaunch the open fix round. Does not append a round. Failures become a dead launch so retry can see them. */
|
|
@@ -712,7 +885,7 @@ function launchOpenFix(config, world, job, host, checkout, open, sandboxFacts) {
|
|
|
712
885
|
writeBreadcrumb(breadcrumbPath(config.kilnDir, job.id), { jobId: job.id, pid: 0, logPath });
|
|
713
886
|
return { jobId: job.id, repo: job.repo, kind: "fix", output: message };
|
|
714
887
|
}
|
|
715
|
-
return launchFixRun(config, job, host, checkout, headSha, specText, sandboxFacts);
|
|
888
|
+
return launchFixRun(config, job, host, checkout, headSha, specText, open.round, sandboxFacts);
|
|
716
889
|
}
|
|
717
890
|
function resolveCommit(host, checkout, sha) {
|
|
718
891
|
try {
|
|
@@ -724,7 +897,7 @@ function resolveCommit(host, checkout, sha) {
|
|
|
724
897
|
}
|
|
725
898
|
}
|
|
726
899
|
/** `workflows/fix.yaml` on `headSha`. Commits on `yak/<run-id>`. Does not push. */
|
|
727
|
-
function launchFixRun(config, job, host, checkout, headSha, specText, sandboxFacts) {
|
|
900
|
+
function launchFixRun(config, job, host, checkout, headSha, specText, round, sandboxFacts) {
|
|
728
901
|
const refused = claudeCredentialPreflight(process.env, config.adapter);
|
|
729
902
|
if (refused)
|
|
730
903
|
return { jobId: job.id, repo: job.repo, kind: "fix", output: refused };
|
|
@@ -736,6 +909,7 @@ function launchFixRun(config, job, host, checkout, headSha, specText, sandboxFac
|
|
|
736
909
|
const blocked = commitAgentSandbox(config, job.id, "fix", workflow, logPath, sandboxFacts);
|
|
737
910
|
if (blocked)
|
|
738
911
|
return { jobId: job.id, repo: job.repo, kind: "fix", output: blocked };
|
|
912
|
+
const hash = promptsHash("fix", config.kilnDir, job.id);
|
|
739
913
|
ensureCheckoutExcludes(checkout);
|
|
740
914
|
const input = workflowInputs(sourceWorkflow, {
|
|
741
915
|
title: job.title,
|
|
@@ -748,6 +922,9 @@ function launchFixRun(config, job, host, checkout, headSha, specText, sandboxFac
|
|
|
748
922
|
...process.env,
|
|
749
923
|
...(repo?.test ? { KILN_TEST: repo.test } : {}),
|
|
750
924
|
KILN_TASK_TITLE: job.title,
|
|
925
|
+
KILN_JOB: job.id,
|
|
926
|
+
KILN_ROUND: String(round),
|
|
927
|
+
KILN_PROMPTS_HASH: hash,
|
|
751
928
|
KILN_VERIFY_WRAPPER: join(packageRoot(), "dist", "truncate-output.js"),
|
|
752
929
|
};
|
|
753
930
|
return spawnLogged(config, host, {
|
|
@@ -943,6 +1120,11 @@ function loadJobFile(file) {
|
|
|
943
1120
|
return { meta: { jobId: job.id }, job };
|
|
944
1121
|
}
|
|
945
1122
|
function scanRuns(config) {
|
|
1123
|
+
const jobs = new Map();
|
|
1124
|
+
for (const entry of loadJobs(config)) {
|
|
1125
|
+
if (entry.job)
|
|
1126
|
+
jobs.set(entry.job.id, entry.job);
|
|
1127
|
+
}
|
|
946
1128
|
const byTag = new Map();
|
|
947
1129
|
for (const repo of Object.values(config.repos)) {
|
|
948
1130
|
for (const runDir of listRunDirs(repo.path)) {
|
|
@@ -951,6 +1133,21 @@ function scanRuns(config) {
|
|
|
951
1133
|
if (!started?.tag || !parseJobTag(started.tag))
|
|
952
1134
|
continue;
|
|
953
1135
|
const runId = basename(runDir);
|
|
1136
|
+
const jobId = parseJobTag(started.tag);
|
|
1137
|
+
if (jobId) {
|
|
1138
|
+
const job = jobs.get(jobId);
|
|
1139
|
+
const testCommand = job ? config.repos[job.repo]?.test : undefined;
|
|
1140
|
+
projectRunJournal({
|
|
1141
|
+
kilnDir: config.kilnDir,
|
|
1142
|
+
jobId,
|
|
1143
|
+
runId,
|
|
1144
|
+
runDir,
|
|
1145
|
+
workflow: job?.workflow ?? "",
|
|
1146
|
+
repo: job?.repo ?? "",
|
|
1147
|
+
...(testCommand ? { testCommand } : {}),
|
|
1148
|
+
events,
|
|
1149
|
+
});
|
|
1150
|
+
}
|
|
954
1151
|
const status = classify(events);
|
|
955
1152
|
const gateDecision = readApproveDecision(runDir);
|
|
956
1153
|
const title = readRunTitle(runDir);
|
|
@@ -958,6 +1155,7 @@ function scanRuns(config) {
|
|
|
958
1155
|
const stepPlan = readRunStepPlan(runDir);
|
|
959
1156
|
const stepEvents = stepEventsFrom(events);
|
|
960
1157
|
const sandboxError = sandboxFailureDetail(stepEvents);
|
|
1158
|
+
const cost = costFromJournal(events);
|
|
961
1159
|
const pid = livePid(events);
|
|
962
1160
|
const activityMs = eventActivityMs(events) ?? fileMtimeMs(join(runDir, "journal.jsonl"));
|
|
963
1161
|
const finishedReason = finished && typeof finished.reason === "string" ? finished.reason : undefined;
|
|
@@ -976,6 +1174,7 @@ function scanRuns(config) {
|
|
|
976
1174
|
...(activityMs !== undefined ? { activityMs } : {}),
|
|
977
1175
|
...(finishedReason ? { finishedReason } : {}),
|
|
978
1176
|
...(sandboxError ? { sandboxError } : {}),
|
|
1177
|
+
cost,
|
|
979
1178
|
};
|
|
980
1179
|
const list = byTag.get(started.tag) ?? [];
|
|
981
1180
|
list.push(run);
|