@lchase/kiln 0.9.0 → 0.11.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 +195 -472
- package/assets/serve.js +121 -6
- package/dist/attention.js +13 -4
- package/dist/board-view.js +688 -0
- package/dist/board.js +0 -97
- 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 +396 -316
- package/dist/serve.js +252 -13
- package/dist/settings.js +9 -1
- package/package.json +4 -3
- package/workflows/fix.yaml +1 -1
package/dist/events.js
CHANGED
|
@@ -9,7 +9,8 @@ import { redactDeep } from "./redact.js";
|
|
|
9
9
|
* parses it. A write failure is reported and does not escape to the caller.
|
|
10
10
|
*
|
|
11
11
|
* `run_id` and `step` are null when the moment has none. `job_id` is null
|
|
12
|
-
*
|
|
12
|
+
* for `config.reloaded`, `kiln.paused`, `kiln.resumed`, and `budget.blocked`,
|
|
13
|
+
* which are not a job.
|
|
13
14
|
*/
|
|
14
15
|
export const EVENT_NAMES = [
|
|
15
16
|
"job.added",
|
|
@@ -27,6 +28,9 @@ export const EVENT_NAMES = [
|
|
|
27
28
|
"worktree.kept",
|
|
28
29
|
"config.reloaded",
|
|
29
30
|
"needs-human",
|
|
31
|
+
"kiln.paused",
|
|
32
|
+
"kiln.resumed",
|
|
33
|
+
"budget.blocked",
|
|
30
34
|
];
|
|
31
35
|
const ISO_UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/;
|
|
32
36
|
const once = z.string().min(1).optional();
|
|
@@ -80,14 +84,24 @@ const GateResolved = z.object({
|
|
|
80
84
|
edit: z.string().min(1).optional(),
|
|
81
85
|
once,
|
|
82
86
|
});
|
|
87
|
+
const costFields = {
|
|
88
|
+
/** Sum of yak `budget.consumed` tokens. Null when the journal did not report any. */
|
|
89
|
+
tokens: z.number().nonnegative().nullable().optional(),
|
|
90
|
+
/** Sum of reported dollars. Null when the journal did not report any. Never invented. */
|
|
91
|
+
usd: z.number().nonnegative().nullable().optional(),
|
|
92
|
+
};
|
|
83
93
|
const RunFailed = z.object({
|
|
84
94
|
reason: z.string().min(1).optional(),
|
|
85
95
|
detail: z.string().min(1).optional(),
|
|
86
96
|
status: z.string().min(1).optional(),
|
|
97
|
+
class: z.enum(["transient", "deterministic", "budget"]).optional(),
|
|
98
|
+
fingerprint: z.string().min(1).optional(),
|
|
99
|
+
...costFields,
|
|
87
100
|
once,
|
|
88
101
|
});
|
|
89
102
|
const RunFinished = z.object({
|
|
90
103
|
status: z.literal("ok"),
|
|
104
|
+
...costFields,
|
|
91
105
|
once,
|
|
92
106
|
});
|
|
93
107
|
const PrOpened = z.object({
|
|
@@ -110,6 +124,13 @@ const NeedsHuman = z.object({
|
|
|
110
124
|
reason: z.string().min(1),
|
|
111
125
|
once,
|
|
112
126
|
});
|
|
127
|
+
const FactoryToggle = z.object({ once });
|
|
128
|
+
const BudgetBlocked = z.object({
|
|
129
|
+
day: z.string().min(1),
|
|
130
|
+
usd: z.number().nonnegative(),
|
|
131
|
+
cap: z.number().nonnegative(),
|
|
132
|
+
once,
|
|
133
|
+
});
|
|
113
134
|
const envelope = {
|
|
114
135
|
id: z.string().min(1),
|
|
115
136
|
ts: z.string().regex(ISO_UTC, "ts must be ISO-8601 UTC"),
|
|
@@ -137,6 +158,24 @@ export const EventSchema = z.discriminatedUnion("event", [
|
|
|
137
158
|
properties: ConfigReloaded,
|
|
138
159
|
}),
|
|
139
160
|
z.object({ ...envelope, event: z.literal("needs-human"), job_id: z.string().min(1), properties: NeedsHuman }),
|
|
161
|
+
z.object({
|
|
162
|
+
...envelope,
|
|
163
|
+
event: z.literal("kiln.paused"),
|
|
164
|
+
job_id: z.string().min(1).nullable(),
|
|
165
|
+
properties: FactoryToggle,
|
|
166
|
+
}),
|
|
167
|
+
z.object({
|
|
168
|
+
...envelope,
|
|
169
|
+
event: z.literal("kiln.resumed"),
|
|
170
|
+
job_id: z.string().min(1).nullable(),
|
|
171
|
+
properties: FactoryToggle,
|
|
172
|
+
}),
|
|
173
|
+
z.object({
|
|
174
|
+
...envelope,
|
|
175
|
+
event: z.literal("budget.blocked"),
|
|
176
|
+
job_id: z.string().min(1).nullable(),
|
|
177
|
+
properties: BudgetBlocked,
|
|
178
|
+
}),
|
|
140
179
|
]);
|
|
141
180
|
const LOCK_WAIT_MS = 5_000;
|
|
142
181
|
const pauseBuf = new Int32Array(new SharedArrayBuffer(4));
|
package/dist/failure.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { redact } from "./redact.js";
|
|
3
|
+
/**
|
|
4
|
+
* One failed run is transient, deterministic, or budget.
|
|
5
|
+
* Transient may retry with backoff inside the attempt cap.
|
|
6
|
+
* Deterministic does not. A cap hit is budget and stops in Needs human.
|
|
7
|
+
*/
|
|
8
|
+
export const FAILURE_CLASSES = ["transient", "deterministic", "budget"];
|
|
9
|
+
const TRANSIENT = [
|
|
10
|
+
/\b429\b/,
|
|
11
|
+
/rate limit/i,
|
|
12
|
+
/too many requests/i,
|
|
13
|
+
/\b5\d\d\b/,
|
|
14
|
+
/internal server error/i,
|
|
15
|
+
/bad gateway/i,
|
|
16
|
+
/service unavailable/i,
|
|
17
|
+
/gateway timeout/i,
|
|
18
|
+
/ECONNRESET|ECONNREFUSED|ENOTFOUND|ETIMEDOUT|EAI_AGAIN|EPIPE/i,
|
|
19
|
+
/socket hang up/i,
|
|
20
|
+
/network (?:error|unreachable|is down)/i,
|
|
21
|
+
/fetch failed/i,
|
|
22
|
+
/connection (?:reset|refused|timed out|closed)/i,
|
|
23
|
+
/container never started/i,
|
|
24
|
+
/failed to start (?:the )?(?:container|sandbox)/i,
|
|
25
|
+
/docker daemon/i,
|
|
26
|
+
/cannot connect to the docker/i,
|
|
27
|
+
/OCI runtime/i,
|
|
28
|
+
/error creating container/i,
|
|
29
|
+
/sandbox (?:start|flake)/i,
|
|
30
|
+
];
|
|
31
|
+
const DETERMINISTIC = [
|
|
32
|
+
/\berror TS\d+\b/,
|
|
33
|
+
/type error/i,
|
|
34
|
+
/cannot find name/i,
|
|
35
|
+
/is not assignable/i,
|
|
36
|
+
/syntax error/i,
|
|
37
|
+
/\bAssertionError\b/,
|
|
38
|
+
/\b(?:tests?|assertion) failed\b/i,
|
|
39
|
+
/\bfailing tests?\b/i,
|
|
40
|
+
/\bbuild failed\b/i,
|
|
41
|
+
/\bcompilation failed\b/i,
|
|
42
|
+
/\bcompile error\b/i,
|
|
43
|
+
/npm ERR!/i,
|
|
44
|
+
];
|
|
45
|
+
/** A container or sandbox that did not start. Those are transient. */
|
|
46
|
+
export function isSandboxStartFlake(text) {
|
|
47
|
+
return /container never started|failed to start (?:the )?(?:container|sandbox)|docker daemon|cannot connect to the docker|OCI runtime|error creating container|sandbox (?:start|flake)/i.test(text);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Strip timestamps, temp and worktree paths, hex ids, and line numbers so
|
|
51
|
+
* the same failure hashes the same way across runs.
|
|
52
|
+
*/
|
|
53
|
+
export function normalizeFailureText(text) {
|
|
54
|
+
let out = redact(text).replace(/\r\n/g, "\n").trim();
|
|
55
|
+
out = out.replace(/\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?/g, "<ts>");
|
|
56
|
+
out = out.replace(/\b\d{2}:\d{2}:\d{2}(?:\.\d+)?\b/g, "<ts>");
|
|
57
|
+
out = out.replace(/(?:\/private)?\/(?:tmp|var\/folders)\/[^\s'")\]:]+/g, "<tmp>");
|
|
58
|
+
out = out.replace(/[^\s'")\]]*[/\\]\.yak[/\\]worktrees[/\\][^\s'")\]:]+/g, "<worktree>");
|
|
59
|
+
out = out.replace(/[^\s'")\]]+[/\\]worktrees[/\\][^\s'")\]:]+/g, "<worktree>");
|
|
60
|
+
out = out.replace(/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi, "<id>");
|
|
61
|
+
out = out.replace(/\b[0-9a-f]{8,}\b/gi, "<id>");
|
|
62
|
+
out = out.replace(/:(\d+)(?::\d+)?\b/g, ":<line>");
|
|
63
|
+
out = out.replace(/\bline\s+\d+\b/gi, "line <line>");
|
|
64
|
+
out = out.replace(/[ \t]+/g, " ");
|
|
65
|
+
out = out.replace(/\n{3,}/g, "\n\n");
|
|
66
|
+
return out.toLowerCase();
|
|
67
|
+
}
|
|
68
|
+
/** Stable id for one normalized failure. */
|
|
69
|
+
export function failureFingerprint(text) {
|
|
70
|
+
return createHash("sha256").update(normalizeFailureText(text)).digest("hex");
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Transient when the text is a network, rate-limit, 5xx, or start flake.
|
|
74
|
+
* A test, build, or type error is deterministic and wins over a transient
|
|
75
|
+
* pattern in the same text. Anything else is deterministic.
|
|
76
|
+
*/
|
|
77
|
+
export function classifyFailureText(text) {
|
|
78
|
+
if (DETERMINISTIC.some((pattern) => pattern.test(text)))
|
|
79
|
+
return "deterministic";
|
|
80
|
+
if (TRANSIENT.some((pattern) => pattern.test(text)))
|
|
81
|
+
return "transient";
|
|
82
|
+
return "deterministic";
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* The only decision for what a recorded failure does next.
|
|
86
|
+
* Attempt cap wins over the fingerprint breaker when both trip on the same run.
|
|
87
|
+
*/
|
|
88
|
+
export function decideFailure(input) {
|
|
89
|
+
const fingerprint = failureFingerprint(input.text);
|
|
90
|
+
const deterministicHit = DETERMINISTIC.some((pattern) => pattern.test(input.text));
|
|
91
|
+
const transientHit = TRANSIENT.some((pattern) => pattern.test(input.text));
|
|
92
|
+
// Stall, crash, orphan, and a bare non-zero exit stay transient unless the
|
|
93
|
+
// text is a test, build, or type error, or the failure is blocked.
|
|
94
|
+
let failureClass = deterministicHit
|
|
95
|
+
? "deterministic"
|
|
96
|
+
: transientHit || (input.operational && !input.blocked)
|
|
97
|
+
? "transient"
|
|
98
|
+
: "deterministic";
|
|
99
|
+
let retry = failureClass === "transient" && !input.blocked;
|
|
100
|
+
let needsHuman;
|
|
101
|
+
if (input.attempt >= input.maxAttempts) {
|
|
102
|
+
failureClass = "budget";
|
|
103
|
+
retry = false;
|
|
104
|
+
needsHuman = "attempt-cap";
|
|
105
|
+
}
|
|
106
|
+
else if (input.sameFingerprint >= input.maxSameFingerprint) {
|
|
107
|
+
retry = false;
|
|
108
|
+
needsHuman = "fingerprint";
|
|
109
|
+
}
|
|
110
|
+
else if (input.blocked || failureClass === "deterministic") {
|
|
111
|
+
retry = false;
|
|
112
|
+
}
|
|
113
|
+
return { class: failureClass, fingerprint, retry, ...(needsHuman ? { needsHuman } : {}) };
|
|
114
|
+
}
|
|
115
|
+
/** Sum yak `budget.consumed` lines. Missing numbers stay null. Never invents a figure. */
|
|
116
|
+
export function costFromJournal(events) {
|
|
117
|
+
let tokens = 0;
|
|
118
|
+
let usd = 0;
|
|
119
|
+
let sawTokens = false;
|
|
120
|
+
let sawUsd = false;
|
|
121
|
+
for (const event of events) {
|
|
122
|
+
if (!event || typeof event !== "object")
|
|
123
|
+
continue;
|
|
124
|
+
const record = event;
|
|
125
|
+
if (record.t !== "budget.consumed")
|
|
126
|
+
continue;
|
|
127
|
+
if (typeof record.tokens === "number" && Number.isFinite(record.tokens) && record.tokens >= 0) {
|
|
128
|
+
tokens += record.tokens;
|
|
129
|
+
sawTokens = true;
|
|
130
|
+
}
|
|
131
|
+
if (typeof record.usd === "number" && Number.isFinite(record.usd) && record.usd >= 0) {
|
|
132
|
+
usd += record.usd;
|
|
133
|
+
sawUsd = true;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
tokens: sawTokens ? tokens : null,
|
|
138
|
+
usd: sawUsd ? usd : null,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
export function fixTrailerLines(input) {
|
|
3
|
+
return [`Kiln-Job: ${input.jobId}`, `Kiln-Round: ${input.round}`, `Kiln-Prompts-Hash: ${input.promptsHash}`];
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* The fix workflow `land` step. `workflows/fix.yaml` keeps the same command.
|
|
7
|
+
* Launch sets `KILN_JOB`, `KILN_ROUND`, and `KILN_PROMPTS_HASH`.
|
|
8
|
+
*/
|
|
9
|
+
export const FIX_LAND_COMMAND = 'git add -A && git diff --cached --quiet || git commit -m "kiln: $KILN_TASK_TITLE" --trailer "Kiln-Job: $KILN_JOB" --trailer "Kiln-Round: $KILN_ROUND" --trailer "Kiln-Prompts-Hash: $KILN_PROMPTS_HASH"';
|
|
10
|
+
/**
|
|
11
|
+
* Make the tip of `dir` carry the fix-round trailers.
|
|
12
|
+
* A dirty tree is committed. A tip that already moved and lacks the trailers
|
|
13
|
+
* is amended. A clean tree still on `baseSha` is left alone.
|
|
14
|
+
*/
|
|
15
|
+
export function ensureFixTrailers(dir, input, baseSha) {
|
|
16
|
+
if (!hasTrailers(dir, input)) {
|
|
17
|
+
if (isDirty(dir)) {
|
|
18
|
+
commit(dir, input, false);
|
|
19
|
+
return "committed";
|
|
20
|
+
}
|
|
21
|
+
const head = revParse(dir, "HEAD");
|
|
22
|
+
if (!head || (baseSha && head === baseSha))
|
|
23
|
+
return "clean";
|
|
24
|
+
commit(dir, input, true);
|
|
25
|
+
return "amended";
|
|
26
|
+
}
|
|
27
|
+
return "present";
|
|
28
|
+
}
|
|
29
|
+
export function commitMessageHasTrailers(message, input) {
|
|
30
|
+
return fixTrailerLines(input).every((line) => message.split("\n").some((entry) => entry.trim() === line));
|
|
31
|
+
}
|
|
32
|
+
function hasTrailers(dir, input) {
|
|
33
|
+
const message = git(dir, ["log", "-1", "--format=%B"]);
|
|
34
|
+
if (message.status !== 0)
|
|
35
|
+
return false;
|
|
36
|
+
return commitMessageHasTrailers(message.stdout, input);
|
|
37
|
+
}
|
|
38
|
+
function isDirty(dir) {
|
|
39
|
+
const status = git(dir, ["status", "--porcelain"]);
|
|
40
|
+
if (status.status !== 0)
|
|
41
|
+
return false;
|
|
42
|
+
return status.stdout.trim().length > 0;
|
|
43
|
+
}
|
|
44
|
+
function revParse(dir, ref) {
|
|
45
|
+
const result = git(dir, ["rev-parse", ref]);
|
|
46
|
+
if (result.status !== 0)
|
|
47
|
+
return undefined;
|
|
48
|
+
const sha = result.stdout.trim();
|
|
49
|
+
return sha || undefined;
|
|
50
|
+
}
|
|
51
|
+
function commit(dir, input, amend) {
|
|
52
|
+
if (!amend) {
|
|
53
|
+
const added = git(dir, ["add", "-A"]);
|
|
54
|
+
if (added.status !== 0)
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const args = ["commit"];
|
|
58
|
+
if (amend)
|
|
59
|
+
args.push("--amend", "--no-edit");
|
|
60
|
+
else
|
|
61
|
+
args.push("-m", `kiln: ${input.title}`);
|
|
62
|
+
for (const line of fixTrailerLines(input))
|
|
63
|
+
args.push("--trailer", line);
|
|
64
|
+
git(dir, args);
|
|
65
|
+
}
|
|
66
|
+
function git(cwd, args) {
|
|
67
|
+
const result = spawnSync("git", args, {
|
|
68
|
+
cwd,
|
|
69
|
+
encoding: "utf8",
|
|
70
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
|
|
71
|
+
});
|
|
72
|
+
return { status: result.status ?? 1, stdout: result.stdout ?? "" };
|
|
73
|
+
}
|
package/dist/ledger.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readEvents } from "./events.js";
|
|
2
2
|
import { redact } from "./redact.js";
|
|
3
|
+
import { formatSpend, spendSnapshot } from "./rails.js";
|
|
3
4
|
/**
|
|
4
5
|
* Interventions are derived from the event log. Nothing is stored beside it.
|
|
5
6
|
* A human had to act when a gate was rejected, a job was retried by hand,
|
|
@@ -64,9 +65,14 @@ export function ledgerRows(events, options = {}) {
|
|
|
64
65
|
return rows;
|
|
65
66
|
}
|
|
66
67
|
export function renderLedger(events, options = {}) {
|
|
68
|
+
const spend = options.spend ??
|
|
69
|
+
(options.dailyDollarCap !== undefined || options.now
|
|
70
|
+
? spendSnapshot(events, options.dailyDollarCap, options.now ?? new Date())
|
|
71
|
+
: undefined);
|
|
72
|
+
const head = spend ? `${formatSpend(spend)}\n` : "";
|
|
67
73
|
const rows = ledgerRows(events, options);
|
|
68
74
|
if (rows.length === 0)
|
|
69
|
-
return redact(
|
|
75
|
+
return redact(`${head}0 interventions\n`);
|
|
70
76
|
const lines = [];
|
|
71
77
|
let grand = 0;
|
|
72
78
|
let index = 0;
|
|
@@ -90,10 +96,12 @@ export function renderLedger(events, options = {}) {
|
|
|
90
96
|
}
|
|
91
97
|
lines.push("");
|
|
92
98
|
lines.push(`${grand} ${grand === 1 ? "intervention" : "interventions"}`);
|
|
93
|
-
return redact(`${lines.join("\n")}\n`);
|
|
99
|
+
return redact(`${head}${lines.join("\n")}\n`);
|
|
94
100
|
}
|
|
95
101
|
export function renderLedgerFile(kilnDir, options = {}) {
|
|
96
|
-
|
|
102
|
+
const events = readEvents(kilnDir);
|
|
103
|
+
const spend = spendSnapshot(events, options.dailyDollarCap, options.now ?? new Date());
|
|
104
|
+
return renderLedger(events, { ...options, spend });
|
|
97
105
|
}
|
|
98
106
|
function reasonsFor(event) {
|
|
99
107
|
const reasons = [];
|
package/dist/project.js
CHANGED
|
@@ -3,6 +3,7 @@ import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
|
3
3
|
import { createRequire } from "node:module";
|
|
4
4
|
import { basename, join, relative, sep } from "node:path";
|
|
5
5
|
import { appendEvent } from "./events.js";
|
|
6
|
+
import { costFromJournal, decideFailure } from "./failure.js";
|
|
6
7
|
import { jobBasename, packageRoot } from "./paths.js";
|
|
7
8
|
/**
|
|
8
9
|
* Project a yak journal into the kiln event log. Called when a run is
|
|
@@ -186,25 +187,40 @@ function recordGateResolved(input, event) {
|
|
|
186
187
|
});
|
|
187
188
|
}
|
|
188
189
|
function recordRunFinished(input, event) {
|
|
190
|
+
const cost = costFromJournal(input.events);
|
|
189
191
|
if (event.status === "ok") {
|
|
190
192
|
appendEvent(input.kilnDir, {
|
|
191
193
|
job_id: input.jobId,
|
|
192
194
|
run_id: input.runId,
|
|
193
195
|
event: "run.finished",
|
|
194
|
-
properties: { status: "ok", once: "run.finished" },
|
|
196
|
+
properties: { status: "ok", tokens: cost.tokens, usd: cost.usd, once: "run.finished" },
|
|
195
197
|
});
|
|
196
198
|
return;
|
|
197
199
|
}
|
|
198
|
-
|
|
200
|
+
// A normal failure is classified in recoverRuns, which owns the retry decision.
|
|
201
|
+
// A cancelled run is not recovered, so the class is recorded here.
|
|
202
|
+
if (event.status !== "failed" || event.reason !== "cancelled")
|
|
199
203
|
return;
|
|
200
|
-
const
|
|
204
|
+
const decision = decideFailure({
|
|
205
|
+
text: "cancelled",
|
|
206
|
+
operational: false,
|
|
207
|
+
blocked: true,
|
|
208
|
+
attempt: 1,
|
|
209
|
+
maxAttempts: Number.MAX_SAFE_INTEGER,
|
|
210
|
+
sameFingerprint: 1,
|
|
211
|
+
maxSameFingerprint: Number.MAX_SAFE_INTEGER,
|
|
212
|
+
});
|
|
201
213
|
appendEvent(input.kilnDir, {
|
|
202
214
|
job_id: input.jobId,
|
|
203
215
|
run_id: input.runId,
|
|
204
216
|
event: "run.failed",
|
|
205
217
|
properties: {
|
|
206
218
|
status: "failed",
|
|
207
|
-
|
|
219
|
+
reason: "cancelled",
|
|
220
|
+
class: decision.class,
|
|
221
|
+
fingerprint: decision.fingerprint,
|
|
222
|
+
tokens: cost.tokens,
|
|
223
|
+
usd: cost.usd,
|
|
208
224
|
once: `run.failed:${input.runId}`,
|
|
209
225
|
},
|
|
210
226
|
});
|
package/dist/rails.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { appendEvent, readEvents } from "./events.js";
|
|
2
|
+
export function localDay(now) {
|
|
3
|
+
const year = now.getFullYear();
|
|
4
|
+
const month = String(now.getMonth() + 1).padStart(2, "0");
|
|
5
|
+
const day = String(now.getDate()).padStart(2, "0");
|
|
6
|
+
return `${year}-${month}-${day}`;
|
|
7
|
+
}
|
|
8
|
+
export function isPaused(kilnDir) {
|
|
9
|
+
return pausedFrom(readEvents(kilnDir, { warn: () => { } }));
|
|
10
|
+
}
|
|
11
|
+
export function pausedFrom(events) {
|
|
12
|
+
let paused = false;
|
|
13
|
+
for (const event of events) {
|
|
14
|
+
if (event.event === "kiln.paused")
|
|
15
|
+
paused = true;
|
|
16
|
+
else if (event.event === "kiln.resumed")
|
|
17
|
+
paused = false;
|
|
18
|
+
}
|
|
19
|
+
return paused;
|
|
20
|
+
}
|
|
21
|
+
/** Append a pause event when the factory is running. Already paused is a no-op. */
|
|
22
|
+
export function pauseFactory(kilnDir, now = new Date()) {
|
|
23
|
+
if (pausedFrom(readEvents(kilnDir, { warn: () => { } })))
|
|
24
|
+
return false;
|
|
25
|
+
appendEvent(kilnDir, {
|
|
26
|
+
job_id: null,
|
|
27
|
+
event: "kiln.paused",
|
|
28
|
+
properties: {},
|
|
29
|
+
ts: now.toISOString(),
|
|
30
|
+
});
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
/** Append a resume event when the factory is paused. Already running is a no-op. */
|
|
34
|
+
export function resumeFactory(kilnDir, now = new Date()) {
|
|
35
|
+
if (!pausedFrom(readEvents(kilnDir, { warn: () => { } })))
|
|
36
|
+
return false;
|
|
37
|
+
appendEvent(kilnDir, {
|
|
38
|
+
job_id: null,
|
|
39
|
+
event: "kiln.resumed",
|
|
40
|
+
properties: {},
|
|
41
|
+
ts: now.toISOString(),
|
|
42
|
+
});
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
export function spendSnapshot(events, cap, now = new Date()) {
|
|
46
|
+
const day = localDay(now);
|
|
47
|
+
let usd = 0;
|
|
48
|
+
for (const event of events) {
|
|
49
|
+
if (localDay(new Date(event.ts)) !== day)
|
|
50
|
+
continue;
|
|
51
|
+
const amount = dollarCost(event);
|
|
52
|
+
if (amount === undefined)
|
|
53
|
+
continue;
|
|
54
|
+
usd += amount;
|
|
55
|
+
}
|
|
56
|
+
const limit = cap === undefined ? null : cap;
|
|
57
|
+
return {
|
|
58
|
+
day,
|
|
59
|
+
usd,
|
|
60
|
+
cap: limit,
|
|
61
|
+
blocked: limit !== null && usd >= limit,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
export function spendFor(kilnDir, cap, now = new Date()) {
|
|
65
|
+
return spendSnapshot(readEvents(kilnDir, { warn: () => { } }), cap, now);
|
|
66
|
+
}
|
|
67
|
+
/** One event per local day when the dollar cap is blocking new runs. */
|
|
68
|
+
export function recordDailyCap(kilnDir, spend, now = new Date()) {
|
|
69
|
+
if (!spend.blocked || spend.cap === null)
|
|
70
|
+
return;
|
|
71
|
+
appendEvent(kilnDir, {
|
|
72
|
+
job_id: null,
|
|
73
|
+
event: "budget.blocked",
|
|
74
|
+
properties: {
|
|
75
|
+
day: spend.day,
|
|
76
|
+
usd: spend.usd,
|
|
77
|
+
cap: spend.cap,
|
|
78
|
+
once: `daily-cap:${spend.day}`,
|
|
79
|
+
},
|
|
80
|
+
ts: now.toISOString(),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
export function formatSpend(spend) {
|
|
84
|
+
const cap = spend.cap === null ? "no cap" : `$${spend.cap.toFixed(2)}`;
|
|
85
|
+
return `today ${spend.day} $${spend.usd.toFixed(2)} / ${cap}`;
|
|
86
|
+
}
|
|
87
|
+
export function holdDetail(paused, spend) {
|
|
88
|
+
if (paused)
|
|
89
|
+
return "Paused. New runs wait until kiln resume.";
|
|
90
|
+
if (spend.blocked && spend.cap !== null) {
|
|
91
|
+
return `Daily cap reached ($${spend.usd.toFixed(2)} of $${spend.cap.toFixed(2)}). New runs wait until the local day rolls over.`;
|
|
92
|
+
}
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Revise notes written since the latest `run.started` for this job.
|
|
97
|
+
* The next launch appends them to the spec, which is the next prompt's input.
|
|
98
|
+
*/
|
|
99
|
+
export function reviseNotesSinceRun(events, jobId) {
|
|
100
|
+
const notes = [];
|
|
101
|
+
for (const event of events) {
|
|
102
|
+
if (event.job_id !== jobId)
|
|
103
|
+
continue;
|
|
104
|
+
if (event.event === "run.started") {
|
|
105
|
+
notes.length = 0;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (event.event === "gate.resolved" && event.properties.decision === "revise" && event.properties.note) {
|
|
109
|
+
notes.push(event.properties.note);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return notes;
|
|
113
|
+
}
|
|
114
|
+
export function withReviseNotes(spec, notes) {
|
|
115
|
+
if (notes.length === 0)
|
|
116
|
+
return spec;
|
|
117
|
+
return [
|
|
118
|
+
spec.trimEnd(),
|
|
119
|
+
"",
|
|
120
|
+
"## Human note",
|
|
121
|
+
"",
|
|
122
|
+
"Apply every note below on this round. A person typed it at the gate.",
|
|
123
|
+
"",
|
|
124
|
+
notes.join("\n\n"),
|
|
125
|
+
"",
|
|
126
|
+
].join("\n");
|
|
127
|
+
}
|
|
128
|
+
/** Fingerprint or attempt-cap stop that a later manual retry has not cleared. */
|
|
129
|
+
export function stopReasonFromEvents(events, jobId) {
|
|
130
|
+
let reason;
|
|
131
|
+
for (const event of events) {
|
|
132
|
+
if (event.job_id !== jobId)
|
|
133
|
+
continue;
|
|
134
|
+
if (event.event === "job.retried") {
|
|
135
|
+
reason = undefined;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (event.event !== "needs-human")
|
|
139
|
+
continue;
|
|
140
|
+
if (event.properties.reason === "fingerprint" || event.properties.reason === "attempt-cap") {
|
|
141
|
+
reason = event.properties.reason;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return reason;
|
|
145
|
+
}
|
|
146
|
+
/** How many `run.failed` lines for this job already carry `fingerprint`. */
|
|
147
|
+
export function fingerprintCount(events, jobId, fingerprint) {
|
|
148
|
+
let count = 0;
|
|
149
|
+
for (const event of events) {
|
|
150
|
+
if (event.job_id !== jobId || event.event !== "run.failed")
|
|
151
|
+
continue;
|
|
152
|
+
if (event.properties.fingerprint === fingerprint)
|
|
153
|
+
count += 1;
|
|
154
|
+
}
|
|
155
|
+
return count;
|
|
156
|
+
}
|
|
157
|
+
function dollarCost(event) {
|
|
158
|
+
if (event.event !== "run.finished" && event.event !== "run.failed")
|
|
159
|
+
return undefined;
|
|
160
|
+
const usd = event.properties.usd;
|
|
161
|
+
if (typeof usd !== "number" || !Number.isFinite(usd) || usd < 0)
|
|
162
|
+
return undefined;
|
|
163
|
+
return usd;
|
|
164
|
+
}
|
package/dist/reconcile.js
CHANGED
|
@@ -10,10 +10,32 @@ export function inFlightCount(observation) {
|
|
|
10
10
|
function inFlight(job, observation) {
|
|
11
11
|
if (job.error || job.feedbackExhausted || job.feedbackAmbiguous)
|
|
12
12
|
return false;
|
|
13
|
+
// A cap or fingerprint stop is waiting on a person. It does not hold a slot.
|
|
14
|
+
if (job.retriesExhausted || job.stopReason)
|
|
15
|
+
return false;
|
|
13
16
|
const state = jobState(job, observation);
|
|
14
17
|
return state === "running" || state === "needs-human" || state === "approved";
|
|
15
18
|
}
|
|
16
19
|
/**
|
|
20
|
+
* The only job-state transition. First match wins.
|
|
21
|
+
*
|
|
22
|
+
* | when | state |
|
|
23
|
+
* | fix awaiting publish | in-review |
|
|
24
|
+
* | launching, and the selected run is absent, retired, or a fix | running |
|
|
25
|
+
* | feedback exhausted or ambiguous, landed, pull request merged | done |
|
|
26
|
+
* | feedback exhausted or ambiguous, landed, pull request closed | failed |
|
|
27
|
+
* | feedback exhausted or ambiguous, landed, pull request still open | needs-human |
|
|
28
|
+
* | selected run not retired | that run: landed, rejected, failed, dead, needs-human, approved, running |
|
|
29
|
+
* | retired run that landed | done, in-review, or failed from the pull request |
|
|
30
|
+
* | retired run whose gate rejected | rejected |
|
|
31
|
+
* | retired suspended run | needs-human |
|
|
32
|
+
* | attempt cap or fingerprint breaker | needs-human |
|
|
33
|
+
* | retired, failed, or dead run | failed |
|
|
34
|
+
* | launching breadcrumb | running |
|
|
35
|
+
* | dependencies unmet | blocked |
|
|
36
|
+
* | launch error | failed |
|
|
37
|
+
* | otherwise | queued |
|
|
38
|
+
*
|
|
17
39
|
* `deliver=local`: `done` is a finished run whose gate says approve.
|
|
18
40
|
* `deliver=pr`: that landed run is `in-review` while the pull request is open
|
|
19
41
|
* (and in the gap before tick opens it) and `done` only once it has merged.
|
|
@@ -21,6 +43,7 @@ function inFlight(job, observation) {
|
|
|
21
43
|
* a slot and does not unblock dependents. An ok run with no readable
|
|
22
44
|
* decision is `failed`, so a missing artifact cannot unblock the next job.
|
|
23
45
|
* An investigation is `done` on approve and never `in-review`.
|
|
46
|
+
* A cap or fingerprint stop is `needs-human` and does not hold a slot.
|
|
24
47
|
*/
|
|
25
48
|
export function jobState(job, observation) {
|
|
26
49
|
if (job.fixAwaitingPublish)
|
|
@@ -42,8 +65,8 @@ export function jobState(job, observation) {
|
|
|
42
65
|
return "rejected";
|
|
43
66
|
if (job.run?.status === "suspended")
|
|
44
67
|
return "needs-human";
|
|
45
|
-
if (job.retriesExhausted)
|
|
46
|
-
return "
|
|
68
|
+
if ((job.retriesExhausted || job.stopReason) && !job.launching)
|
|
69
|
+
return "needs-human";
|
|
47
70
|
if (job.runRetired || job.run?.status === "failed" || job.run?.status === "dead")
|
|
48
71
|
return "failed";
|
|
49
72
|
if (job.launching)
|
|
@@ -150,7 +173,7 @@ function canStartFix(job) {
|
|
|
150
173
|
}
|
|
151
174
|
/** A recorded transient failure whose backoff has elapsed. One retry per tick, ahead of a new job. */
|
|
152
175
|
function canRetry(job, observation, now) {
|
|
153
|
-
if (job.error || job.launching || job.retriesExhausted || job.ledgerError)
|
|
176
|
+
if (job.error || job.launching || job.retriesExhausted || job.stopReason || job.ledgerError)
|
|
154
177
|
return false;
|
|
155
178
|
if (job.run && !job.runRetired)
|
|
156
179
|
return false;
|
|
@@ -192,6 +215,9 @@ export function decide(observation, now = new Date()) {
|
|
|
192
215
|
}
|
|
193
216
|
if (inFlightCount(observation) >= observation.maxConcurrent)
|
|
194
217
|
return actions;
|
|
218
|
+
// Pause and the daily dollar cap stop new runs. In-flight work is not touched.
|
|
219
|
+
if (observation.paused || observation.spendBlocked)
|
|
220
|
+
return actions;
|
|
195
221
|
const retry = observation.jobs.find((job) => canRetry(job, observation, now));
|
|
196
222
|
if (retry) {
|
|
197
223
|
actions.push({ type: "retry", jobId: retry.jobId, repo: retry.repo });
|