@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/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
- * only for `config.reloaded`, which is not a job.
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));
@@ -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("0 interventions\n");
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
- return renderLedger(readEvents(kilnDir), options);
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
- if (event.status !== "failed")
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 reason = typeof event.reason === "string" ? clip(event.reason) : undefined;
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
- ...(reason ? { reason } : {}),
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 "dead";
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 });