@lchase/kiln 0.9.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 +5 -5
- package/assets/serve.css +13 -0
- package/assets/serve.js +49 -3
- package/dist/attention.js +13 -4
- package/dist/cli.js +63 -6
- package/dist/config.js +25 -1
- package/dist/driver.js +117 -16
- package/dist/events.js +40 -1
- package/dist/failure.js +140 -0
- package/dist/fix-commit.js +73 -0
- package/dist/ledger.js +11 -3
- package/dist/project.js +20 -4
- package/dist/rails.js +164 -0
- package/dist/reconcile.js +29 -3
- package/dist/reliability.js +53 -25
- package/dist/report.js +19 -5
- package/dist/schemas.js +35 -5
- package/dist/serve-page.js +21 -1
- package/dist/serve.js +70 -3
- package/dist/settings.js +9 -1
- package/package.json +2 -2
- package/workflows/fix.yaml +1 -1
package/README.md
CHANGED
|
@@ -108,7 +108,7 @@ Then set the kiln config to `"sandbox": { "mode": "docker" }`.
|
|
|
108
108
|
|
|
109
109
|
`kiln tick` and `kiln serve` name what is missing when sandbox mode is on: the docker binary, the docker daemon, each image, and the credential. They also refuse an installed `@lchase/yak` that does not pass `allowedDomains` from the agent step into the sandbox proxy. `@lchase/yak` 0.8.0 does. An older package drops `sandbox`, `image`, and `allowedDomains` while loading YAML and opens the proxy with only `api.anthropic.com`, so the job would run on the host with the open internet. Kiln leaves that job failed until `kiln retry`, after the installed package is 0.8.0 or newer.
|
|
110
110
|
|
|
111
|
-
A
|
|
111
|
+
A sandbox or container start flake (the container never started, the daemon was unreachable) is transient and retries with the usual backoff. Any other yak `sandbox-error` is not retried. The card shows the failure detail, and a sandboxed job shows `sandbox · docker`.
|
|
112
112
|
|
|
113
113
|
## Register repos
|
|
114
114
|
|
|
@@ -271,13 +271,13 @@ while true; do npm run kiln -- tick; sleep 30; done
|
|
|
271
271
|
|
|
272
272
|
A job is ready when its dependencies are met and the number of jobs in flight is under `maxConcurrent`. The default is after merged: every job in `dependsOn` is `done`. `--stack` is met when that one upstream has landed. A rejected gate does not unblock the next job. An ok run with no readable approval does not either. An authentication failure does not either: reason `auth-failed` stays in Needs human until `kiln retry`.
|
|
273
273
|
|
|
274
|
-
`stallTimeoutMs` (default `900000`, 15 minutes), `maxAttempts` (default `3`), and `maxFixRounds` (default `2`) live in the kiln config. `stallTimeoutMs` of `0` or below turns stall detection off. A running job with no yak journal event and no log output for that long is killed, process tree included, and marked failed with reason `stalled`. A process that is already gone when `kiln tick` or `kiln serve` starts is `orphaned`. An agent or launch crash is `crashed`. A non-zero exit that is not a gate rejection is `failed`. Those four retry on their own after `10s × 2^(n-1)`, capped at 5 minutes. A rejected gate, a cancelled run, and an ok run with no readable approval do not. After `maxAttempts` the job is `
|
|
274
|
+
`stallTimeoutMs` (default `900000`, 15 minutes), `maxAttempts` (default `3`), and `maxFixRounds` (default `2`) live in the kiln config. `stallTimeoutMs` of `0` or below turns stall detection off. A running job with no yak journal event and no log output for that long is killed, process tree included, and marked failed with reason `stalled`. A process that is already gone when `kiln tick` or `kiln serve` starts is `orphaned`. An agent or launch crash is `crashed`. A non-zero exit that is not a gate rejection is `failed`. Those four retry on their own after `10s × 2^(n-1)`, capped at 5 minutes. A rejected gate, a cancelled run, and an ok run with no readable approval do not. After `maxAttempts` the failure is class `budget` and the job is `needs-human`. The same fingerprint does that after `maxSameFingerprint` (default 3) even sooner. A test, build, or type error is class `deterministic` and is not retried. `repos.<name>.maxAttempts` and `repos.<name>.maxFixRounds` override the global caps. `dailyDollarCap`, when set, blocks new runs once today's recorded dollars reach it. `kiln pause` does the same until `kiln resume`. In-flight runs finish. `kiln revise <id> --note <text>` sends a gated job around again with that note in the next prompt. Each attempt is a file under `<kiln-dir>/attempts/<job-id>.json`, so state stays derived. Status prints `attempts`, `reason`, and `next retry` from the same view `readStatus` returns. On the board, a dead card in Needs human shows `attempt N/M` and the reason. A failure that still has a scheduled retry stays in its stage column instead, with `retrying` and the local time.
|
|
275
275
|
|
|
276
276
|
While a `deliver=pr` job is `in-review`, tick reads that pull request with `gh`: check runs for the head, reviews, and unresolved review comments. The operator's inline comments and unresolved threads count; a top-level note counts only when it starts with `/kiln`, and that prefix is left out of the fix prompt. Comments from `kiln` or a `[bot]` login are ignored, and a resolved thread does not start a round. Pending checks are ignored. A completed failing check on a head that no round has recorded, or review feedback newer than the last round, starts `workflows/fix.yaml` with `yak run --from` that head. The prompt is the original spec plus the failing check names (with a short log excerpt) and the review comment bodies, including file and line. The agent commits on `yak/<run-id>` and does not push. Kiln fast-forwards the open pull request branch. It does not open a second pull request, force-push, merge, approve, or comment. The same head and the same feedback do not start two runs. Each round is a file under `<kiln-dir>/feedback/<job-id>.json`. A stall or crash during a fix uses the attempt ledger above. After `maxFixRounds` the job is `needs-human` with reason `feedback-exhausted`. Feedback Kiln cannot classify is `needs-human` with reason `feedback-ambiguous`. The person merges the pull request.
|
|
277
277
|
|
|
278
278
|
`npm run kiln -- status` lists jobs across repos. When a job has `source`, status prints `source <kind> <ref>`. For `needs-human` it prints `approve: kiln approve <id>` and `reject: kiln reject <id>`. Run those as `npm run kiln -- approve <id>` and `npm run kiln -- reject <id>`. For `done` and `in-review` it prints the branch. `deliver=local` prints `branch yak/<run-id>`. `deliver=pr` prints the remote branch name recorded on the delivery, and the pull request url once one exists. `auth-failed` prints `Claude login rejected (401) — check CLAUDE_CODE_OAUTH_TOKEN / claude login, then Retry`. For an investigation whose findings file exists, it prints `findings <path>`. That path is `findingsPath` on the same status view `kiln serve` renders, and the console links it. For `blocked` on a dependency id that has no job file, it prints `blocked on missing job <id>`. While an upstream pull request is open, it prints `waiting for PR #N to merge`. `npm run kiln -- retry <id>` relaunches a failed or dead job, or delivers again when the only failure is opening the pull request, and clears the attempt ledger.
|
|
279
279
|
|
|
280
|
-
`npm run kiln -- ledger` counts human interventions from `<kiln-home>/events.jsonl`: a rejected gate, a job retried by hand, an edit noted on a gate answer, and a needs-human stop. `--since 7d` (also `12h`, `30m`, `45s`, or an ISO time) keeps events at or after that instant. `--repo <name>` limits the count to one repo. A failed append is reported and does not fail the job. `GET /api/events?job_id=` on `kiln serve` returns that job's lines. A pull request body gains a short Evidence section from the same lines, with tokens and secret env values masked.
|
|
280
|
+
`npm run kiln -- ledger` prints today's recorded dollars against `dailyDollarCap` (or `no cap`) and counts human interventions from `<kiln-home>/events.jsonl`: a rejected gate, a job retried by hand, an edit noted on a gate answer, and a needs-human stop. `--since 7d` (also `12h`, `30m`, `45s`, or an ISO time) keeps events at or after that instant. `--repo <name>` limits the count to one repo. A failed append is reported and does not fail the job. `GET /api/events?job_id=` on `kiln serve` returns that job's lines. A pull request body gains a short Evidence section from the same lines, with tokens and secret env values masked.
|
|
281
281
|
|
|
282
282
|
State is derived from the yak journal, the gate files, and (for `deliver=pr`) `gh pr view`:
|
|
283
283
|
|
|
@@ -286,11 +286,11 @@ State is derived from the yak journal, the gate files, and (for `deliver=pr`) `g
|
|
|
286
286
|
| `queued` | Dependencies are met, and the job has not started |
|
|
287
287
|
| `blocked` | An after-merged dependency is not `done`, or a stacked dependency has not landed. A missing job id is named |
|
|
288
288
|
| `running` | Yak is alive, or the launch process is alive and has not written a journal |
|
|
289
|
-
| `needs-human` |
|
|
289
|
+
| `needs-human` | A gate is open, feedback is exhausted or ambiguous, the attempt cap is used, or the same fingerprint has failed `maxSameFingerprint` times. `kiln retry` clears an attempt-cap or fingerprint stop. `kiln revise --note` sends a gate back with a note |
|
|
290
290
|
| `approved` | The gate says approve and the run has not finished. This still holds a slot |
|
|
291
291
|
| `rejected` | The run finished ok and the gate says reject |
|
|
292
292
|
| `failed` | The run failed, the launch died before a journal, the run finished ok with no readable approval, a pull request could not be opened, a Claude login was rejected (`auth-failed`, not retried), or a transient failure is waiting to retry |
|
|
293
|
-
| `dead` |
|
|
293
|
+
| `dead` | A journal whose process is already gone, before the next tick records it. Status prints the cancel command when a process is gone |
|
|
294
294
|
| `in-review` | `deliver=pr` and the run has landed. The pull request is open, or the next tick still has to open it |
|
|
295
295
|
| `done` | `deliver=local`: the run finished ok and the gate says approve. `deliver=pr`: that pull request has merged. An investigation is `done` when the gate says approve, including when the repo is `deliver=pr`. It does not enter `in-review` |
|
|
296
296
|
|
package/assets/serve.css
CHANGED
|
@@ -61,6 +61,19 @@ header {
|
|
|
61
61
|
}
|
|
62
62
|
.ver { font-size: 12px; line-height: 16px; color: var(--faint); align-self: flex-end; margin: 0 0 4px -4px; }
|
|
63
63
|
h1 { margin: 0; font-size: 18px; line-height: 24px; font-weight: 500; }
|
|
64
|
+
.spend { font-family: var(--mono); font-size: 12px; line-height: 16px; color: var(--muted); }
|
|
65
|
+
.pause { margin: 0; }
|
|
66
|
+
.pause button, .d-actions .revise input {
|
|
67
|
+
font: inherit;
|
|
68
|
+
color: var(--ink);
|
|
69
|
+
background: var(--card);
|
|
70
|
+
border: 1px solid var(--line-strong);
|
|
71
|
+
border-radius: 6px;
|
|
72
|
+
padding: 6px 10px;
|
|
73
|
+
}
|
|
74
|
+
.pause button[aria-pressed="true"] { color: var(--human); }
|
|
75
|
+
.d-actions .revise { display: flex; gap: 8px; align-items: center; }
|
|
76
|
+
.d-actions .revise input { min-width: 16rem; }
|
|
64
77
|
.status { margin-left: auto; font-family: var(--mono); font-size: 12px; line-height: 16px; color: var(--muted); }
|
|
65
78
|
.status::before {
|
|
66
79
|
content: "";
|
package/assets/serve.js
CHANGED
|
@@ -605,6 +605,8 @@
|
|
|
605
605
|
base: repo.base || "",
|
|
606
606
|
prTitle: repo.prTitle || "",
|
|
607
607
|
branch: repo.branch || "",
|
|
608
|
+
maxAttempts: repo.maxAttempts != null ? String(repo.maxAttempts) : "",
|
|
609
|
+
maxFixRounds: repo.maxFixRounds != null ? String(repo.maxFixRounds) : "",
|
|
608
610
|
};
|
|
609
611
|
}
|
|
610
612
|
return out;
|
|
@@ -625,7 +627,11 @@
|
|
|
625
627
|
var attempts = document.getElementById("hint-attempts");
|
|
626
628
|
var stall = document.getElementById("hint-stall");
|
|
627
629
|
var rounds = document.getElementById("hint-fix");
|
|
628
|
-
if (attempts) attempts.textContent = "Default " + (settingsDefaults.maxAttempts != null ? settingsDefaults.maxAttempts : 3) + ". Transient failures before the job
|
|
630
|
+
if (attempts) attempts.textContent = "Default " + (settingsDefaults.maxAttempts != null ? settingsDefaults.maxAttempts : 3) + ". Transient failures before the job needs a person.";
|
|
631
|
+
var fingerprint = document.getElementById("hint-fingerprint");
|
|
632
|
+
var cap = document.getElementById("hint-cap");
|
|
633
|
+
if (fingerprint) fingerprint.textContent = "Default " + (settingsDefaults.maxSameFingerprint != null ? settingsDefaults.maxSameFingerprint : 3) + ". Identical fingerprints before the job needs a person.";
|
|
634
|
+
if (cap) cap.textContent = "Empty means no daily cap. Recorded dollars, local day.";
|
|
629
635
|
if (stall) stall.textContent = "Default " + (settingsDefaults.stallTimeoutMs != null ? settingsDefaults.stallTimeoutMs : 900000) + " (15 minutes). 0 disables stall detection.";
|
|
630
636
|
if (rounds) rounds.textContent = "Default " + (settingsDefaults.maxFixRounds != null ? settingsDefaults.maxFixRounds : 2) + ". Fix rounds before the job needs a person.";
|
|
631
637
|
var attemptInput = document.getElementById("max-attempts");
|
|
@@ -634,6 +640,8 @@
|
|
|
634
640
|
if (attemptInput) attemptInput.placeholder = String(settingsDefaults.maxAttempts != null ? settingsDefaults.maxAttempts : 3);
|
|
635
641
|
if (stallInput) stallInput.placeholder = String(settingsDefaults.stallTimeoutMs != null ? settingsDefaults.stallTimeoutMs : 900000);
|
|
636
642
|
if (roundInput) roundInput.placeholder = String(settingsDefaults.maxFixRounds != null ? settingsDefaults.maxFixRounds : 2);
|
|
643
|
+
var fingerprintInput = document.getElementById("max-same-fingerprint");
|
|
644
|
+
if (fingerprintInput) fingerprintInput.placeholder = String(settingsDefaults.maxSameFingerprint != null ? settingsDefaults.maxSameFingerprint : 3);
|
|
637
645
|
}
|
|
638
646
|
|
|
639
647
|
function fillGlobal(data) {
|
|
@@ -644,6 +652,10 @@
|
|
|
644
652
|
if (attempts) attempts.value = data.maxAttempts != null ? String(data.maxAttempts) : "";
|
|
645
653
|
if (stall) stall.value = data.stallTimeoutMs != null ? String(data.stallTimeoutMs) : "";
|
|
646
654
|
if (rounds) rounds.value = data.maxFixRounds != null ? String(data.maxFixRounds) : "";
|
|
655
|
+
var fingerprint = document.getElementById("max-same-fingerprint");
|
|
656
|
+
var cap = document.getElementById("daily-dollar-cap");
|
|
657
|
+
if (fingerprint) fingerprint.value = data.maxSameFingerprint != null ? String(data.maxSameFingerprint) : "";
|
|
658
|
+
if (cap) cap.value = data.dailyDollarCap != null ? String(data.dailyDollarCap) : "";
|
|
647
659
|
setPressed("#sandbox-mode [data-sandbox]", "data-sandbox", data.sandbox && data.sandbox.mode === "docker" ? "docker" : "off");
|
|
648
660
|
var domains = document.getElementById("sandbox-domains");
|
|
649
661
|
if (domains) domains.value = data.sandbox && data.sandbox.allowDomains ? data.sandbox.allowDomains.join("\n") : "";
|
|
@@ -674,12 +686,16 @@
|
|
|
674
686
|
var baseInput = document.getElementById("repo-base");
|
|
675
687
|
var titleInput = document.getElementById("repo-pr-title");
|
|
676
688
|
var branchInput = document.getElementById("repo-branch");
|
|
689
|
+
var attemptsInput = document.getElementById("repo-max-attempts");
|
|
690
|
+
var roundsInput = document.getElementById("repo-max-fix-rounds");
|
|
677
691
|
if (nameInput) nameInput.value = name || "";
|
|
678
692
|
if (pathInput) pathInput.value = repo.path || "";
|
|
679
693
|
if (testInput) testInput.value = repo.test || "";
|
|
680
694
|
if (baseInput) baseInput.value = repo.base || "";
|
|
681
695
|
if (titleInput) titleInput.value = repo.prTitle || "";
|
|
682
696
|
if (branchInput) branchInput.value = repo.branch || "";
|
|
697
|
+
if (attemptsInput) attemptsInput.value = repo.maxAttempts != null && repo.maxAttempts !== "" ? String(repo.maxAttempts) : "";
|
|
698
|
+
if (roundsInput) roundsInput.value = repo.maxFixRounds != null && repo.maxFixRounds !== "" ? String(repo.maxFixRounds) : "";
|
|
683
699
|
setPressed("#repo-deliver [data-deliver]", "data-deliver", repo.deliver === "pr" ? "pr" : "local");
|
|
684
700
|
var browser = document.getElementById("repo-browser");
|
|
685
701
|
if (browser) { browser.hidden = true; browser.innerHTML = ""; }
|
|
@@ -698,6 +714,8 @@
|
|
|
698
714
|
base: value("repo-base"),
|
|
699
715
|
prTitle: value("repo-pr-title"),
|
|
700
716
|
branch: value("repo-branch"),
|
|
717
|
+
maxAttempts: value("repo-max-attempts"),
|
|
718
|
+
maxFixRounds: value("repo-max-fix-rounds"),
|
|
701
719
|
};
|
|
702
720
|
}
|
|
703
721
|
|
|
@@ -763,6 +781,13 @@
|
|
|
763
781
|
renderRepoList();
|
|
764
782
|
}
|
|
765
783
|
|
|
784
|
+
function optionalNumber(raw) {
|
|
785
|
+
var text = String(raw == null ? "" : raw).trim();
|
|
786
|
+
if (!text) return undefined;
|
|
787
|
+
if (!/^\d+(\.\d+)?$/.test(text)) return text;
|
|
788
|
+
return Number(text);
|
|
789
|
+
}
|
|
790
|
+
|
|
766
791
|
function optionalInt(raw) {
|
|
767
792
|
var text = String(raw == null ? "" : raw).trim();
|
|
768
793
|
if (!text) return undefined;
|
|
@@ -791,6 +816,10 @@
|
|
|
791
816
|
if (repo.base) entry.base = repo.base;
|
|
792
817
|
if (repo.prTitle) entry.prTitle = repo.prTitle;
|
|
793
818
|
if (repo.branch) entry.branch = repo.branch;
|
|
819
|
+
var repoAttempts = optionalInt(repo.maxAttempts);
|
|
820
|
+
var repoRounds = optionalInt(repo.maxFixRounds);
|
|
821
|
+
if (repoAttempts !== undefined) entry.maxAttempts = repoAttempts;
|
|
822
|
+
if (repoRounds !== undefined) entry.maxFixRounds = repoRounds;
|
|
794
823
|
repos[name] = entry;
|
|
795
824
|
}
|
|
796
825
|
var body = { revision: settingsDoc ? settingsDoc.revision : "", repos: repos };
|
|
@@ -800,6 +829,10 @@
|
|
|
800
829
|
if (attempts !== undefined) body.maxAttempts = attempts;
|
|
801
830
|
if (stall !== undefined) body.stallTimeoutMs = stall;
|
|
802
831
|
if (rounds !== undefined) body.maxFixRounds = rounds;
|
|
832
|
+
var fingerprint = optionalInt(inputValue("max-same-fingerprint"));
|
|
833
|
+
var cap = optionalNumber(inputValue("daily-dollar-cap"));
|
|
834
|
+
if (fingerprint !== undefined) body.maxSameFingerprint = fingerprint;
|
|
835
|
+
if (cap !== undefined) body.dailyDollarCap = cap;
|
|
803
836
|
var mode = selectedAttr("#sandbox-mode [data-sandbox]", "data-sandbox", "off");
|
|
804
837
|
var domains = domainList(inputValue("sandbox-domains"));
|
|
805
838
|
var sandbox = {};
|
|
@@ -820,7 +853,8 @@
|
|
|
820
853
|
if (repoField) {
|
|
821
854
|
setSettingsTab("repos");
|
|
822
855
|
if (Object.prototype.hasOwnProperty.call(repoDraft, repoField[1])) startEdit(repoField[1]);
|
|
823
|
-
|
|
856
|
+
var slotName = repoField[2] === "maxAttempts" ? "repoAttempts" : repoField[2] === "maxFixRounds" ? "repoRounds" : repoField[2];
|
|
857
|
+
showSlot(slotName, message);
|
|
824
858
|
continue;
|
|
825
859
|
}
|
|
826
860
|
if (key.indexOf("sandbox.allowDomains") === 0) {
|
|
@@ -828,7 +862,7 @@
|
|
|
828
862
|
showSlot("sandbox.allowDomains", message);
|
|
829
863
|
continue;
|
|
830
864
|
}
|
|
831
|
-
if (key === "sandbox.mode" || key === "maxAttempts" || key === "stallTimeoutMs" || key === "maxFixRounds") {
|
|
865
|
+
if (key === "sandbox.mode" || key === "maxAttempts" || key === "stallTimeoutMs" || key === "maxFixRounds" || key === "maxSameFingerprint" || key === "dailyDollarCap") {
|
|
832
866
|
setSettingsTab("global");
|
|
833
867
|
showSlot(key, message);
|
|
834
868
|
continue;
|
|
@@ -1154,6 +1188,18 @@
|
|
|
1154
1188
|
location.reload();
|
|
1155
1189
|
return;
|
|
1156
1190
|
}
|
|
1191
|
+
var toggle = document.getElementById("pause-toggle");
|
|
1192
|
+
var form = document.getElementById("pause-form");
|
|
1193
|
+
if (toggle && form && data && typeof data.paused === "boolean") {
|
|
1194
|
+
toggle.textContent = data.paused ? "Resume" : "Pause";
|
|
1195
|
+
toggle.setAttribute("aria-pressed", data.paused ? "true" : "false");
|
|
1196
|
+
form.setAttribute("action", data.paused ? "/api/resume" : "/api/pause");
|
|
1197
|
+
}
|
|
1198
|
+
var spend = document.getElementById("spend");
|
|
1199
|
+
if (spend && data && data.spend) {
|
|
1200
|
+
var capText = data.spend.cap == null ? "no cap" : "$" + Number(data.spend.cap).toFixed(2);
|
|
1201
|
+
spend.textContent = "today " + data.spend.day + " $" + Number(data.spend.usd).toFixed(2) + " / " + capText;
|
|
1202
|
+
}
|
|
1157
1203
|
stamp(true);
|
|
1158
1204
|
})
|
|
1159
1205
|
.catch(function () { stamp(false); });
|
package/dist/attention.js
CHANGED
|
@@ -24,6 +24,8 @@ const RETRYABLE_REASONS = new Set(["stalled", "crashed", "orphaned", "failed"]);
|
|
|
24
24
|
* so a waiting retry is not a call for a person.
|
|
25
25
|
*/
|
|
26
26
|
export function scheduledRetry(job) {
|
|
27
|
+
if (job.waitReason)
|
|
28
|
+
return false;
|
|
27
29
|
if (job.state !== "failed")
|
|
28
30
|
return false;
|
|
29
31
|
if (!job.nextRetryAt || !Number.isFinite(Date.parse(job.nextRetryAt)))
|
|
@@ -92,13 +94,18 @@ function toItem(job) {
|
|
|
92
94
|
reason: attentionReason(job, tail),
|
|
93
95
|
...(tail ? { errorTail: tail } : {}),
|
|
94
96
|
...(findings ? { findings } : {}),
|
|
95
|
-
retry: job.state === "failed" || job.state === "dead"
|
|
97
|
+
retry: (job.state === "failed" || job.state === "dead" || (job.state === "needs-human" && Boolean(job.stopReason))) &&
|
|
98
|
+
job.gates.length === 0,
|
|
96
99
|
...(since ? { since } : {}),
|
|
97
100
|
};
|
|
98
101
|
}
|
|
99
102
|
export function attentionReason(job, tail = errorTail(job)) {
|
|
100
103
|
switch (job.state) {
|
|
101
104
|
case "needs-human": {
|
|
105
|
+
if (job.stopReason === "attempt-cap")
|
|
106
|
+
return "Attempt cap reached";
|
|
107
|
+
if (job.stopReason === "fingerprint")
|
|
108
|
+
return "Same failure repeated";
|
|
102
109
|
if (job.feedbackReason)
|
|
103
110
|
return job.feedbackReason;
|
|
104
111
|
const question = gateQuestion(job);
|
|
@@ -109,6 +116,8 @@ export function attentionReason(job, tail = errorTail(job)) {
|
|
|
109
116
|
return AUTH_FAILED_LINE;
|
|
110
117
|
return job.sandboxError ? `Sandbox: ${oneLine(job.sandboxError)}` : "Run died before it finished";
|
|
111
118
|
case "failed": {
|
|
119
|
+
if (job.waitReason)
|
|
120
|
+
return job.waitReason;
|
|
112
121
|
if (job.lastFailureReason === "auth-failed")
|
|
113
122
|
return AUTH_FAILED_LINE;
|
|
114
123
|
if (job.sandboxError)
|
|
@@ -124,8 +133,6 @@ export function attentionReason(job, tail = errorTail(job)) {
|
|
|
124
133
|
return "In review";
|
|
125
134
|
case "invalid":
|
|
126
135
|
return job.invalidReason ? `Invalid: ${oneLine(job.invalidReason)}` : "Job file is invalid";
|
|
127
|
-
case "blocked":
|
|
128
|
-
return oneLine(waitingOn(job));
|
|
129
136
|
case "running": {
|
|
130
137
|
const current = job.steps.find((step) => step.phase === "current" || step.phase === "failed");
|
|
131
138
|
return current ? `Running ${current.id}` : "Running";
|
|
@@ -133,7 +140,9 @@ export function attentionReason(job, tail = errorTail(job)) {
|
|
|
133
140
|
case "approved":
|
|
134
141
|
return "Approved, still finishing";
|
|
135
142
|
case "queued":
|
|
136
|
-
return "Queued, ready to launch";
|
|
143
|
+
return job.waitReason ?? "Queued, ready to launch";
|
|
144
|
+
case "blocked":
|
|
145
|
+
return job.waitReason ?? oneLine(waitingOn(job));
|
|
137
146
|
case "done":
|
|
138
147
|
return job.prUrl ? "Merged" : "Done";
|
|
139
148
|
case "rejected":
|
package/dist/cli.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { addRepo, renderRepoList, resolveKilnConfig } from "./config.js";
|
|
3
3
|
import { renderSandboxStatus, runSandboxBuild } from "./sandbox.js";
|
|
4
|
-
import { answerJob, retryJob, runStatus, runTick } from "./driver.js";
|
|
4
|
+
import { answerJob, retryJob, reviseJob, runStatus, runTick } from "./driver.js";
|
|
5
|
+
import { pauseFactory, resumeFactory } from "./rails.js";
|
|
5
6
|
import { parseSince, renderLedgerFile } from "./ledger.js";
|
|
6
7
|
import { startServe } from "./serve.js";
|
|
7
8
|
import { addFromFile } from "./intake/file.js";
|
|
@@ -19,7 +20,10 @@ function usage() {
|
|
|
19
20
|
" kiln status [jobId] [--config <file>]",
|
|
20
21
|
" kiln approve <jobId> [--config <file>]",
|
|
21
22
|
" kiln reject <jobId> [--config <file>]",
|
|
23
|
+
" kiln revise <jobId> --note <text> [--config <file>]",
|
|
22
24
|
" kiln retry <jobId> [--config <file>]",
|
|
25
|
+
" kiln pause [--config <file>]",
|
|
26
|
+
" kiln resume [--config <file>]",
|
|
23
27
|
" kiln ledger [--since 7d] [--repo <name>] [--config <file>]",
|
|
24
28
|
" kiln sandbox [build] [--config <file>]",
|
|
25
29
|
" kiln serve [--port 4100] [--tick <seconds>|--no-tick] [--config <file>]",
|
|
@@ -66,7 +70,13 @@ function usage() {
|
|
|
66
70
|
"or a process that is already gone when tick or serve starts (orphaned) retries",
|
|
67
71
|
"after 10s, 20s, 40s, and so on, capped at 5 minutes, up to maxAttempts",
|
|
68
72
|
"A rejected Claude login (401 or an invalid bearer token) is auth-failed and is not retried.",
|
|
69
|
-
"(default 3).
|
|
73
|
+
"(default 3, overridable per repo). Hitting the cap is a budget failure and the",
|
|
74
|
+
"job needs a person. The same failure fingerprint stops the job after",
|
|
75
|
+
"maxSameFingerprint (default 3). A test, build, or type error is not retried.",
|
|
76
|
+
"A rejected gate is not retried. revise sends the job around again with the",
|
|
77
|
+
"note in the next prompt. pause stops new runs until resume. In-flight runs",
|
|
78
|
+
"finish. A daily dollar cap, when set, does the same once today's recorded",
|
|
79
|
+
"spend reaches it. ledger prints that spend.",
|
|
70
80
|
"Attempts are files under the kiln home, not fields on the job.",
|
|
71
81
|
"Events are one JSON line each in <kiln-home>/events.jsonl. A write",
|
|
72
82
|
"failure is reported and does not stop the job. kiln ledger counts",
|
|
@@ -227,6 +237,13 @@ function parseFlags(argv) {
|
|
|
227
237
|
bag.noTick = true;
|
|
228
238
|
continue;
|
|
229
239
|
}
|
|
240
|
+
if (flag === "--note") {
|
|
241
|
+
if (!next || next.startsWith("--"))
|
|
242
|
+
usage();
|
|
243
|
+
bag.note = next;
|
|
244
|
+
i += 1;
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
230
247
|
if (flag === "--since") {
|
|
231
248
|
if (!next || next.startsWith("--"))
|
|
232
249
|
usage();
|
|
@@ -380,7 +397,7 @@ function sandboxCommand(argv) {
|
|
|
380
397
|
function tickCommand(argv) {
|
|
381
398
|
const flags = parseFlags(argv);
|
|
382
399
|
rejectServeFlags(flags);
|
|
383
|
-
if (flags.positionals.length !== 0 || flags.test || hasIntakeFlags(flags) || flags.deliver || flags.base || flags.stack) {
|
|
400
|
+
if (flags.positionals.length !== 0 || flags.test || flags.note || hasIntakeFlags(flags) || flags.deliver || flags.base || flags.stack) {
|
|
384
401
|
usage();
|
|
385
402
|
}
|
|
386
403
|
const config = resolveKilnConfig({ configPath: flags.configPath });
|
|
@@ -389,7 +406,7 @@ function tickCommand(argv) {
|
|
|
389
406
|
function statusCommand(argv) {
|
|
390
407
|
const flags = parseFlags(argv);
|
|
391
408
|
rejectServeFlags(flags);
|
|
392
|
-
if (flags.positionals.length > 1 || flags.test || hasIntakeFlags(flags) || flags.deliver || flags.base || flags.stack) {
|
|
409
|
+
if (flags.positionals.length > 1 || flags.test || flags.note || hasIntakeFlags(flags) || flags.deliver || flags.base || flags.stack) {
|
|
393
410
|
usage();
|
|
394
411
|
}
|
|
395
412
|
const config = resolveKilnConfig({ configPath: flags.configPath });
|
|
@@ -399,7 +416,7 @@ function retryCommand(argv) {
|
|
|
399
416
|
const flags = parseFlags(argv);
|
|
400
417
|
rejectServeFlags(flags);
|
|
401
418
|
const jobId = flags.positionals[0];
|
|
402
|
-
if (!jobId || flags.positionals.length !== 1 || flags.test || hasIntakeFlags(flags) || flags.deliver || flags.base || flags.stack) {
|
|
419
|
+
if (!jobId || flags.positionals.length !== 1 || flags.test || flags.note || hasIntakeFlags(flags) || flags.deliver || flags.base || flags.stack) {
|
|
403
420
|
usage();
|
|
404
421
|
}
|
|
405
422
|
const config = resolveKilnConfig({ configPath: flags.configPath });
|
|
@@ -429,13 +446,41 @@ function ledgerCommand(argv) {
|
|
|
429
446
|
process.stdout.write(renderLedgerFile(config.kilnDir, {
|
|
430
447
|
...(since ? { since } : {}),
|
|
431
448
|
...(flags.repo ? { repo: flags.repo } : {}),
|
|
449
|
+
...(config.dailyDollarCap !== undefined ? { dailyDollarCap: config.dailyDollarCap } : {}),
|
|
432
450
|
}));
|
|
433
451
|
}
|
|
452
|
+
function reviseCommand(argv) {
|
|
453
|
+
const flags = parseFlags(argv);
|
|
454
|
+
rejectServeFlags(flags);
|
|
455
|
+
const jobId = flags.positionals[0];
|
|
456
|
+
if (!jobId ||
|
|
457
|
+
flags.positionals.length !== 1 ||
|
|
458
|
+
!flags.note ||
|
|
459
|
+
flags.test ||
|
|
460
|
+
hasIntakeFlags(flags) ||
|
|
461
|
+
flags.deliver ||
|
|
462
|
+
flags.base ||
|
|
463
|
+
flags.stack) {
|
|
464
|
+
usage();
|
|
465
|
+
}
|
|
466
|
+
const config = resolveKilnConfig({ configPath: flags.configPath });
|
|
467
|
+
process.stdout.write(reviseJob(config, jobId, flags.note));
|
|
468
|
+
}
|
|
469
|
+
function pauseCommand(argv, pause) {
|
|
470
|
+
const flags = parseFlags(argv);
|
|
471
|
+
rejectServeFlags(flags);
|
|
472
|
+
if (flags.positionals.length !== 0 || flags.test || hasIntakeFlags(flags) || flags.note || flags.deliver || flags.base || flags.stack) {
|
|
473
|
+
usage();
|
|
474
|
+
}
|
|
475
|
+
const config = resolveKilnConfig({ configPath: flags.configPath });
|
|
476
|
+
const changed = pause ? pauseFactory(config.kilnDir) : resumeFactory(config.kilnDir);
|
|
477
|
+
process.stdout.write(pause ? (changed ? "paused\n" : "already paused\n") : changed ? "resumed\n" : "already running\n");
|
|
478
|
+
}
|
|
434
479
|
function gateCommand(choice, argv) {
|
|
435
480
|
const flags = parseFlags(argv);
|
|
436
481
|
rejectServeFlags(flags);
|
|
437
482
|
const jobId = flags.positionals[0];
|
|
438
|
-
if (!jobId || flags.positionals.length !== 1 || flags.test || hasIntakeFlags(flags) || flags.deliver || flags.base || flags.stack) {
|
|
483
|
+
if (!jobId || flags.positionals.length !== 1 || flags.test || flags.note || hasIntakeFlags(flags) || flags.deliver || flags.base || flags.stack) {
|
|
439
484
|
usage();
|
|
440
485
|
}
|
|
441
486
|
const config = resolveKilnConfig({ configPath: flags.configPath });
|
|
@@ -511,6 +556,18 @@ async function main() {
|
|
|
511
556
|
retryCommand(argv.slice(1));
|
|
512
557
|
return;
|
|
513
558
|
}
|
|
559
|
+
if (command === "revise") {
|
|
560
|
+
reviseCommand(argv.slice(1));
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
if (command === "pause") {
|
|
564
|
+
pauseCommand(argv.slice(1), true);
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
if (command === "resume") {
|
|
568
|
+
pauseCommand(argv.slice(1), false);
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
514
571
|
if (command === "ledger") {
|
|
515
572
|
ledgerCommand(argv.slice(1));
|
|
516
573
|
return;
|
package/dist/config.js
CHANGED
|
@@ -2,7 +2,7 @@ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "no
|
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
4
4
|
import { KilnConfigError } from "./errors.js";
|
|
5
|
-
import { DEFAULT_MAX_ATTEMPTS, DEFAULT_MAX_FIX_ROUNDS, DEFAULT_STALL_TIMEOUT_MS, KilnConfigFileSchema, REPO_NAME, } from "./schemas.js";
|
|
5
|
+
import { DEFAULT_MAX_ATTEMPTS, DEFAULT_MAX_FIX_ROUNDS, DEFAULT_MAX_SAME_FINGERPRINT, DEFAULT_STALL_TIMEOUT_MS, KilnConfigFileSchema, REPO_NAME, } from "./schemas.js";
|
|
6
6
|
const CONFIG_NAME = "kiln.config.json";
|
|
7
7
|
export function loadKilnConfig(configPath, options = {}) {
|
|
8
8
|
const home = options.home ?? homedir();
|
|
@@ -31,6 +31,8 @@ export function loadKilnConfig(configPath, options = {}) {
|
|
|
31
31
|
...(entry.base ? { base: entry.base } : {}),
|
|
32
32
|
...(entry.prTitle ? { prTitle: entry.prTitle } : {}),
|
|
33
33
|
...(entry.branch ? { branchTemplate: entry.branch } : {}),
|
|
34
|
+
...(entry.maxAttempts !== undefined ? { maxAttempts: entry.maxAttempts } : {}),
|
|
35
|
+
...(entry.maxFixRounds !== undefined ? { maxFixRounds: entry.maxFixRounds } : {}),
|
|
34
36
|
};
|
|
35
37
|
}
|
|
36
38
|
const kilnDir = configDir;
|
|
@@ -46,6 +48,8 @@ export function loadKilnConfig(configPath, options = {}) {
|
|
|
46
48
|
stallTimeoutMs: parsed.data.stallTimeoutMs ?? DEFAULT_STALL_TIMEOUT_MS,
|
|
47
49
|
maxAttempts: parsed.data.maxAttempts ?? DEFAULT_MAX_ATTEMPTS,
|
|
48
50
|
maxFixRounds: parsed.data.maxFixRounds ?? DEFAULT_MAX_FIX_ROUNDS,
|
|
51
|
+
maxSameFingerprint: parsed.data.maxSameFingerprint ?? DEFAULT_MAX_SAME_FINGERPRINT,
|
|
52
|
+
...(parsed.data.dailyDollarCap !== undefined ? { dailyDollarCap: parsed.data.dailyDollarCap } : {}),
|
|
49
53
|
adapter: parsed.data.adapter ?? "claude-code",
|
|
50
54
|
sandbox: parsed.data.sandbox ?? {},
|
|
51
55
|
workflowSandbox: workflowSandboxFrom(parsed.data.workflows),
|
|
@@ -112,6 +116,12 @@ export function addRepo(options) {
|
|
|
112
116
|
const branchTemplate = previousString(previous, "branch");
|
|
113
117
|
if (branchTemplate)
|
|
114
118
|
entry.branch = branchTemplate;
|
|
119
|
+
const maxAttempts = previousNumber(previous, "maxAttempts");
|
|
120
|
+
if (maxAttempts !== undefined)
|
|
121
|
+
entry.maxAttempts = maxAttempts;
|
|
122
|
+
const maxFixRounds = previousNumber(previous, "maxFixRounds");
|
|
123
|
+
if (maxFixRounds !== undefined)
|
|
124
|
+
entry.maxFixRounds = maxFixRounds;
|
|
115
125
|
repos[options.name] = entry;
|
|
116
126
|
raw.repos = repos;
|
|
117
127
|
const parsed = KilnConfigFileSchema.safeParse(raw);
|
|
@@ -138,6 +148,14 @@ export function addRepo(options) {
|
|
|
138
148
|
lines.push(`wrote ${configPath}`);
|
|
139
149
|
return `${lines.join("\n")}\n`;
|
|
140
150
|
}
|
|
151
|
+
/** Attempt cap for one repo: the repo override, otherwise the global cap. */
|
|
152
|
+
export function attemptsFor(config, repo) {
|
|
153
|
+
return config.repos[repo]?.maxAttempts ?? config.maxAttempts;
|
|
154
|
+
}
|
|
155
|
+
/** Fix-round cap for one repo: the repo override, otherwise the global cap. */
|
|
156
|
+
export function fixRoundsFor(config, repo) {
|
|
157
|
+
return config.repos[repo]?.maxFixRounds ?? config.maxFixRounds;
|
|
158
|
+
}
|
|
141
159
|
export function renderRepoList(config) {
|
|
142
160
|
const names = Object.keys(config.repos).sort();
|
|
143
161
|
if (names.length === 0)
|
|
@@ -226,6 +244,12 @@ function previousString(previous, key) {
|
|
|
226
244
|
const value = previous[key];
|
|
227
245
|
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
228
246
|
}
|
|
247
|
+
function previousNumber(previous, key) {
|
|
248
|
+
if (typeof previous !== "object" || previous === null || !(key in previous))
|
|
249
|
+
return undefined;
|
|
250
|
+
const value = previous[key];
|
|
251
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
252
|
+
}
|
|
229
253
|
function previousDeliver(previous) {
|
|
230
254
|
const value = previousString(previous, "deliver");
|
|
231
255
|
if (value === "local" || value === "pr")
|