@lchase/kiln 0.3.0 → 0.4.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/driver.js CHANGED
@@ -1,16 +1,18 @@
1
1
  import { spawn, spawnSync } from "node:child_process";
2
2
  import { randomUUID } from "node:crypto";
3
- import { closeSync, existsSync, linkSync, mkdirSync, openSync, readdirSync, readFileSync, rmSync, writeFileSync, } from "node:fs";
3
+ import { closeSync, existsSync, linkSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, rmSync, statSync, writeFileSync, } from "node:fs";
4
4
  import { basename, dirname, isAbsolute, join } from "node:path";
5
+ import { buildAttention } from "./attention.js";
5
6
  import { clearLaunchError, deliverJob, readDelivery, readLaunchError, resolveLaunchBase, writeDelivery, writeLaunchError, isMerged, } from "./delivery.js";
6
7
  import { appendInvestigationFindings, existingFindingsPath, findingsFilePath, INVESTIGATION_WORKFLOW, readFindingsMarkdown, writeFindings, } from "./findings.js";
7
8
  import { defaultHost } from "./host.js";
8
9
  import { readJobSpec, resolveJobSpec } from "./jobs.js";
9
10
  import { jobBasename, packageRoot } from "./paths.js";
10
11
  import { decide, isLanded, jobState, } from "./reconcile.js";
12
+ import { fileMtimeMs, isPidAlive, readAttemptLedger, recoverRuns, resetAttemptLedger, } from "./reliability.js";
11
13
  import { gateAnswerCommand, renderStatus, renderTick, statusView, yakCliPath, } from "./report.js";
12
14
  import { JobSchema } from "./schemas.js";
13
- import { materializeWorkflow, workflowInputs, workflowNeedsTestCommand } from "./workflow.js";
15
+ import { leafStepIds, materializeWorkflow, workflowInputs, workflowNeedsTestCommand, workflowStepPlan } from "./workflow.js";
14
16
  const TAG_PREFIX = "kiln:";
15
17
  export function jobTag(jobId) {
16
18
  return `${TAG_PREFIX}${jobId}`;
@@ -25,16 +27,26 @@ export function parseJobTag(tag) {
25
27
  }
26
28
  /** The derived view `kiln status` prints and `kiln serve` renders. */
27
29
  export function readStatus(config, jobId, host = defaultHost()) {
28
- return statusView(observe(config, host).observation, jobId);
30
+ const view = statusView(observe(config, host).observation, jobId);
31
+ return { ...view, attention: buildAttention(view.jobs) };
29
32
  }
30
33
  export function runStatus(config, jobId, host = defaultHost()) {
31
34
  return renderStatus(readStatus(config, jobId, host));
32
35
  }
33
- export function runTick(config, host = defaultHost()) {
36
+ export function runTick(config, host = defaultHost(), options = {}) {
37
+ const startup = options.startup ?? true;
38
+ const now = options.now ?? new Date();
34
39
  return withTickLock(config.kilnDir, () => {
40
+ const notes = recoverRuns(config, observe(config, host).observation.jobs, { startup, now });
35
41
  const world = observe(config, host);
36
- const actions = decide(world.observation);
37
- return apply(config, world, actions, host);
42
+ const actions = decide(world.observation, now);
43
+ return apply(config, world, actions, host, notes);
44
+ });
45
+ }
46
+ /** Startup pass shared by `kiln tick` (via `runTick`) and `kiln serve`. Idempotent. */
47
+ export function startupRecovery(config, host = defaultHost(), now = new Date()) {
48
+ withTickLock(config.kilnDir, () => {
49
+ recoverRuns(config, observe(config, host).observation.jobs, { startup: true, now });
38
50
  });
39
51
  }
40
52
  export function answerJob(config, jobId, choice, host = defaultHost()) {
@@ -65,6 +77,74 @@ export function answerJob(config, jobId, choice, host = defaultHost()) {
65
77
  }
66
78
  return output.endsWith("\n") || output.length === 0 ? output : `${output}\n`;
67
79
  }
80
+ /**
81
+ * Run a failed or dead job again. The board Retry button and `kiln retry`
82
+ * both call this.
83
+ * A landed job whose only failure is opening the pull request is handed to
84
+ * the same deliver path tick uses. Everything else is a new `yak run`, the
85
+ * same launch tick uses. The attempt ledger is cleared either way, and the
86
+ * current run id is ignored so a dead job is not stuck on the old journal.
87
+ * A dead journal is cancelled first.
88
+ */
89
+ export function retryJob(config, jobId, host = defaultHost()) {
90
+ return withTickLock(config.kilnDir, () => {
91
+ const world = observe(config, host);
92
+ const observed = world.observation.jobs.find((candidate) => candidate.jobId === jobId);
93
+ if (!observed)
94
+ throw new Error(`No job ${jobId}.`);
95
+ if (observed.error)
96
+ throw new Error(`Job ${jobId} is invalid: ${observed.error}`);
97
+ const state = jobState(observed, world.observation);
98
+ if (state !== "failed" && state !== "dead") {
99
+ throw new Error(`Job ${jobId} is ${state}, not failed or dead.`);
100
+ }
101
+ const job = world.jobs.get(jobId);
102
+ if (!job)
103
+ throw new Error(`No job ${jobId}.`);
104
+ const crumb = readBreadcrumbs(config.kilnDir).find((candidate) => candidate.jobId === jobId);
105
+ if (crumb && pidAlive(crumb.pid)) {
106
+ throw new Error(`Job ${jobId} is already launching.`);
107
+ }
108
+ const cleared = observed.attemptCount ?? 0;
109
+ resetAttemptLedger(config.kilnDir, jobId, observed.run?.runId);
110
+ rmSync(breadcrumbPath(config.kilnDir, jobId), { force: true });
111
+ clearLaunchError(config.kilnDir, jobId);
112
+ const noun = cleared === 1 ? "attempt" : "attempts";
113
+ const clearedLine = `cleared ${cleared} ${noun}\n`;
114
+ if (shouldRedeliver(observed)) {
115
+ const report = deliverObserved(config, world, jobId, host);
116
+ if (!report.ok)
117
+ throw new Error(report.detail);
118
+ const detail = report.detail.endsWith("\n") || report.detail.length === 0 ? report.detail : `${report.detail}\n`;
119
+ return `retry ${jobId} ${job.repo}\n${clearedLine}${detail}`;
120
+ }
121
+ if (state === "dead" && observed.run) {
122
+ try {
123
+ spawnSync(process.execPath, [yakCliPath(), "cancel", observed.run.runId], {
124
+ cwd: observed.run.checkout,
125
+ encoding: "utf8",
126
+ timeout: 20_000,
127
+ });
128
+ }
129
+ catch {
130
+ // Cancel is the same cleanup status prints. The new run still supersedes the journal.
131
+ }
132
+ }
133
+ const logPath = join(config.kilnDir, "logs", `${jobBasename(job.id)}.log`);
134
+ if (existsSync(logPath))
135
+ writeFileSync(logPath, "");
136
+ const report = launchJob(config, world, job, host);
137
+ const output = report.output.endsWith("\n") || report.output.length === 0 ? report.output : `${report.output}\n`;
138
+ return `retry ${report.jobId} ${report.repo}\n${clearedLine}${output}`;
139
+ });
140
+ }
141
+ function shouldRedeliver(job) {
142
+ if (!isLanded(job))
143
+ return false;
144
+ if (job.delivery?.prState === "CLOSED")
145
+ return false;
146
+ return Boolean(job.delivery?.error) && !job.delivery?.prNumber;
147
+ }
68
148
  function gateForChoice(gates, choice) {
69
149
  return (gates.find((gate) => gate.choices?.includes(choice)) ??
70
150
  gates.find((gate) => !gate.choices?.length));
@@ -86,11 +166,16 @@ function observe(config, host) {
86
166
  if (entry.job)
87
167
  records.set(entry.job.id, entry.job);
88
168
  const tag = jobTag(entry.meta.jobId);
89
- const run = runs.get(tag);
169
+ const ledgerResult = readAttemptLedger(config.kilnDir, entry.meta.jobId);
170
+ const ledger = ledgerResult && ledgerResult !== "corrupt" ? ledgerResult : undefined;
171
+ const picked = selectRun(runs.get(tag) ?? [], ledger);
172
+ const run = picked.run;
90
173
  const crumb = breadcrumbs.find((candidate) => candidate.jobId === entry.meta.jobId);
91
- if (crumb && run)
174
+ if (crumb && run && !picked.retired)
92
175
  breadcrumbsToClear.push({ path: crumb.path });
93
176
  const childAlive = crumb ? pidAlive(crumb.pid) : false;
177
+ const launching = Boolean(crumb) && childAlive && (!run || picked.retired);
178
+ const launchFailed = Boolean(crumb) && !childAlive && (!run || picked.retired);
94
179
  const repoKnown = entry.job ? Boolean(config.repos[entry.job.repo]) : false;
95
180
  const repoError = entry.job && !repoKnown ? `Repo ${entry.job.repo} is not registered.` : undefined;
96
181
  const repo = entry.job ? config.repos[entry.job.repo] : undefined;
@@ -103,10 +188,26 @@ function observe(config, host) {
103
188
  ? "stack needs exactly one dependency."
104
189
  : undefined;
105
190
  const logPath = crumb?.logPath || existingLog(config.kilnDir, entry.meta.jobId);
191
+ if (run && logPath)
192
+ run.activityMs = later(run.activityMs, fileMtimeMs(logPath));
193
+ const launchActivityMs = later(crumb ? fileMtimeMs(crumb.path) : undefined, logPath ? fileMtimeMs(logPath) : undefined);
194
+ const attemptCount = ledger?.attempts.length ?? 0;
195
+ const lastFailure = ledger?.attempts.at(-1);
196
+ const exhausted = attemptCount >= config.maxAttempts &&
197
+ attemptCount > 0 &&
198
+ !launching &&
199
+ (picked.retired || !run || run.status === "dead" || run.status === "failed");
106
200
  const specPath = entry.job ? resolveJobSpec(config.kilnDir, entry.job.spec) : undefined;
107
201
  const findingsPath = entry.job?.workflow === INVESTIGATION_WORKFLOW
108
202
  ? existingFindingsPath(config.kilnDir, entry.job.id)
109
203
  : undefined;
204
+ const logTail = readLogTail(logPath);
205
+ const stepPlan = entry.job ? workflowStepPlan(entry.job.workflow) : undefined;
206
+ const diffSummary = run &&
207
+ run.status === "suspended" &&
208
+ entry.job?.workflow !== INVESTIGATION_WORKFLOW
209
+ ? readDiffSummary(run.checkout, run.runId)
210
+ : undefined;
110
211
  jobs.push({
111
212
  jobId: entry.meta.jobId,
112
213
  repo: entry.job?.repo ?? "",
@@ -119,11 +220,23 @@ function observe(config, host) {
119
220
  ...(entry.job?.stack ? { stack: true } : {}),
120
221
  ...(delivery ? { delivery } : {}),
121
222
  ...(findingsPath ? { findingsPath } : {}),
122
- launching: Boolean(crumb) && !run && childAlive,
123
- launchFailed: Boolean(crumb) && !run && !childAlive,
223
+ launching,
224
+ launchFailed,
124
225
  ...(launchError ? { launchError } : {}),
226
+ ...(crumb ? { launchPid: crumb.pid } : {}),
227
+ ...(launchActivityMs !== undefined ? { launchActivityMs } : {}),
125
228
  ...(logPath ? { logPath } : {}),
229
+ ...(entry.job?.createdAt ? { createdAt: entry.job.createdAt } : {}),
230
+ ...(logTail ? { logTail } : {}),
231
+ ...(diffSummary ? { diffSummary } : {}),
232
+ ...(stepPlan && stepPlan.length > 0 ? { stepPlan } : {}),
126
233
  ...(run ? { run } : {}),
234
+ attemptCount,
235
+ ...(lastFailure ? { lastFailureReason: lastFailure.reason } : {}),
236
+ ...(ledger?.nextRetryAt && !exhausted ? { nextRetryAt: ledger.nextRetryAt } : {}),
237
+ ...(exhausted ? { retriesExhausted: true } : {}),
238
+ ...(picked.retired ? { runRetired: true } : {}),
239
+ ...(ledgerResult === "corrupt" ? { ledgerError: "attempt ledger is unreadable" } : {}),
127
240
  ...(entry.meta.error || repoError || stackError
128
241
  ? { error: entry.meta.error ?? repoError ?? stackError }
129
242
  : {}),
@@ -132,13 +245,14 @@ function observe(config, host) {
132
245
  return {
133
246
  observation: {
134
247
  maxConcurrent: config.maxConcurrent,
248
+ maxAttempts: config.maxAttempts,
135
249
  jobs,
136
250
  breadcrumbsToClear,
137
251
  },
138
252
  jobs: records,
139
253
  };
140
254
  }
141
- function apply(config, world, actions, host) {
255
+ function apply(config, world, actions, host, notes = []) {
142
256
  const launches = [];
143
257
  const delivers = [];
144
258
  const findings = [];
@@ -161,9 +275,10 @@ function apply(config, world, actions, host) {
161
275
  if (!job) {
162
276
  throw new Error(`Tick planned a launch for ${action.jobId}, which is not in the observed jobs.`);
163
277
  }
164
- launches.push(launchJob(config, world, job, host));
278
+ const report = launchJob(config, world, job, host);
279
+ launches.push(action.type === "retry" ? { ...report, retry: true } : report);
165
280
  }
166
- return renderTick(world.observation, actions, launches, delivers, findings);
281
+ return renderTick(world.observation, actions, launches, delivers, findings, notes);
167
282
  }
168
283
  /**
169
284
  * Copy an approved investigation's markdown into `<kilnDir>/findings`.
@@ -429,6 +544,12 @@ function scanRuns(config) {
429
544
  const status = classify(events);
430
545
  const gateDecision = readApproveDecision(runDir);
431
546
  const title = readRunTitle(runDir);
547
+ const finished = [...events].reverse().find((event) => event.t === "run.finished");
548
+ const stepPlan = readRunStepPlan(runDir);
549
+ const stepEvents = stepEventsFrom(events);
550
+ const pid = livePid(events);
551
+ const activityMs = eventActivityMs(events) ?? fileMtimeMs(join(runDir, "journal.jsonl"));
552
+ const finishedReason = finished && typeof finished.reason === "string" ? finished.reason : undefined;
432
553
  const run = {
433
554
  runId,
434
555
  checkout: repo.path,
@@ -436,14 +557,175 @@ function scanRuns(config) {
436
557
  gates: status === "suspended" ? openGates(runDir, events) : [],
437
558
  ...(gateDecision ? { gateDecision } : {}),
438
559
  ...(title ? { title } : {}),
560
+ ...(typeof started?.at === "string" ? { startedAt: started.at } : {}),
561
+ ...(typeof finished?.at === "string" ? { finishedAt: finished.at } : {}),
562
+ ...(stepPlan.length > 0 ? { stepPlan } : {}),
563
+ ...(stepEvents.length > 0 ? { stepEvents } : {}),
564
+ ...(pid !== undefined ? { pid } : {}),
565
+ ...(activityMs !== undefined ? { activityMs } : {}),
566
+ ...(finishedReason ? { finishedReason } : {}),
439
567
  };
440
- const previous = byTag.get(started.tag);
441
- if (!previous || run.runId > previous.runId)
442
- byTag.set(started.tag, run);
568
+ const list = byTag.get(started.tag) ?? [];
569
+ list.push(run);
570
+ byTag.set(started.tag, list);
443
571
  }
444
572
  }
445
573
  return byTag;
446
574
  }
575
+ function stepEventsFrom(events) {
576
+ const found = [];
577
+ for (const event of events) {
578
+ if (event.t !== "step.started" && event.t !== "step.completed" && event.t !== "step.failed")
579
+ continue;
580
+ if (!event.stepId)
581
+ continue;
582
+ const detail = event.t === "step.failed" ? formatFailure(event.failure) : undefined;
583
+ found.push({
584
+ t: event.t,
585
+ stepId: event.stepId,
586
+ ...(detail ? { detail } : {}),
587
+ });
588
+ }
589
+ return found;
590
+ }
591
+ function formatFailure(failure) {
592
+ if (!failure)
593
+ return undefined;
594
+ const detail = typeof failure.detail === "string" ? failure.detail.trim() : "";
595
+ const reason = typeof failure.reason === "string" ? failure.reason.trim() : "";
596
+ const text = detail && reason ? `${detail} (${reason})` : detail || reason;
597
+ return text || undefined;
598
+ }
599
+ function readRunStepPlan(runDir) {
600
+ const file = join(runDir, "workflow.json");
601
+ if (!existsSync(file))
602
+ return [];
603
+ try {
604
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
605
+ return leafStepIds(parsed.steps);
606
+ }
607
+ catch {
608
+ return [];
609
+ }
610
+ }
611
+ function readDiffSummary(checkout, runId) {
612
+ const worktree = join(checkout, ".yak", "worktrees", runId);
613
+ if (existsSync(worktree)) {
614
+ const result = spawnSync("git", ["diff", "--stat", "HEAD"], {
615
+ cwd: worktree,
616
+ encoding: "utf8",
617
+ timeout: 5_000,
618
+ });
619
+ const text = result.status === 0 ? result.stdout.trim() : "";
620
+ if (text)
621
+ return clipText(text, 1500);
622
+ }
623
+ return readBuildSummary(join(checkout, ".runs", runId));
624
+ }
625
+ function readBuildSummary(runDir) {
626
+ const dir = join(runDir, "artifacts");
627
+ if (!existsSync(dir))
628
+ return undefined;
629
+ let files;
630
+ try {
631
+ files = readdirSync(dir);
632
+ }
633
+ catch {
634
+ return undefined;
635
+ }
636
+ let best;
637
+ for (const file of files) {
638
+ const match = /^build(?:\.(\d+))?\.json$/.exec(file);
639
+ if (!match)
640
+ continue;
641
+ const iter = match[1] ? Number(match[1]) : 0;
642
+ if (!best || iter >= best.iter)
643
+ best = { iter, file };
644
+ }
645
+ if (!best)
646
+ return undefined;
647
+ try {
648
+ const parsed = JSON.parse(readFileSync(join(dir, best.file), "utf8"));
649
+ if (typeof parsed.summary !== "string" || !parsed.summary.trim())
650
+ return undefined;
651
+ return clipText(parsed.summary.trim(), 280);
652
+ }
653
+ catch {
654
+ return undefined;
655
+ }
656
+ }
657
+ function readLogTail(logPath) {
658
+ if (!logPath)
659
+ return undefined;
660
+ let size;
661
+ try {
662
+ size = statSync(logPath).size;
663
+ }
664
+ catch {
665
+ return undefined;
666
+ }
667
+ if (size <= 0)
668
+ return undefined;
669
+ const take = Math.min(size, 64_000);
670
+ let fd;
671
+ try {
672
+ fd = openSync(logPath, "r");
673
+ const buffer = Buffer.alloc(take);
674
+ readSync(fd, buffer, 0, take, size - take);
675
+ const text = buffer.toString("utf8");
676
+ const lines = text.split(/\r?\n/);
677
+ if (size > take)
678
+ lines.shift();
679
+ while (lines.length > 0 && lines[lines.length - 1] === "")
680
+ lines.pop();
681
+ const tail = lines.slice(-40).join("\n").trim();
682
+ return tail || undefined;
683
+ }
684
+ catch {
685
+ return undefined;
686
+ }
687
+ finally {
688
+ if (fd !== undefined)
689
+ closeSync(fd);
690
+ }
691
+ }
692
+ /**
693
+ * Prefer a run that is not already a recorded failure, so a retry is visible
694
+ * even when its id does not sort after the failed one. Otherwise keep the
695
+ * latest id, which is what a single-run job has always shown.
696
+ */
697
+ function selectRun(runs, ledger) {
698
+ const ignored = new Set(ledger?.ignoredRunIds ?? []);
699
+ const failed = new Set((ledger?.attempts ?? []).flatMap((attempt) => (attempt.runId ? [attempt.runId] : [])));
700
+ const visible = runs.filter((run) => !ignored.has(run.runId));
701
+ visible.sort((left, right) => (left.runId < right.runId ? 1 : left.runId > right.runId ? -1 : 0));
702
+ const fresh = visible.filter((run) => !failed.has(run.runId));
703
+ if (fresh[0])
704
+ return { run: fresh[0], retired: false };
705
+ if (visible[0])
706
+ return { run: visible[0], retired: true };
707
+ return { retired: false };
708
+ }
709
+ function later(left, right) {
710
+ if (left === undefined)
711
+ return right;
712
+ if (right === undefined)
713
+ return left;
714
+ return Math.max(left, right);
715
+ }
716
+ function eventActivityMs(events) {
717
+ let latest;
718
+ for (const event of events) {
719
+ if (!event.at)
720
+ continue;
721
+ const parsed = Date.parse(event.at);
722
+ if (!Number.isFinite(parsed))
723
+ continue;
724
+ if (latest === undefined || parsed > latest)
725
+ latest = parsed;
726
+ }
727
+ return latest;
728
+ }
447
729
  function listRunDirs(checkout) {
448
730
  const runsDir = join(checkout, ".runs");
449
731
  if (!existsSync(runsDir))
@@ -807,15 +1089,7 @@ function releaseLock(lockPath) {
807
1089
  }
808
1090
  }
809
1091
  function pidAlive(pid) {
810
- if (!Number.isInteger(pid) || pid <= 0)
811
- return false;
812
- try {
813
- process.kill(pid, 0);
814
- return true;
815
- }
816
- catch (error) {
817
- return isErrno(error, "EPERM");
818
- }
1092
+ return isPidAlive(pid);
819
1093
  }
820
1094
  function isErrno(error, code) {
821
1095
  return typeof error === "object" && error !== null && "code" in error && error.code === code;
package/dist/reconcile.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import { INVESTIGATION_WORKFLOW } from "./findings.js";
2
+ /** Why a transient attempt failed. Written under `<kilnDir>/attempts`, not on the job. */
3
+ export const FAILURE_REASONS = ["stalled", "orphaned", "crashed", "failed"];
2
4
  export function inFlightCount(observation) {
3
5
  return observation.jobs.filter((job) => inFlight(job, observation)).length;
4
6
  }
@@ -18,33 +20,48 @@ function inFlight(job, observation) {
18
20
  * An investigation is `done` on approve and never `in-review`.
19
21
  */
20
22
  export function jobState(job, observation) {
21
- if (job.launchFailed && !job.run)
22
- return "failed";
23
- if (job.run) {
24
- if (isLanded(job))
25
- return landedState(job);
26
- if (job.run.status === "ok" && job.run.gateDecision === "reject")
27
- return "rejected";
28
- if (job.run.status === "ok")
29
- return "failed";
30
- if (job.run.status === "failed")
31
- return "failed";
32
- if (job.run.status === "dead")
33
- return "dead";
34
- if (job.run.status === "suspended")
35
- return "needs-human";
36
- if (job.run.gateDecision === "approve")
37
- return "approved";
23
+ if (job.launching && (!job.run || job.runRetired))
38
24
  return "running";
39
- }
25
+ if (job.run && !job.runRetired)
26
+ return activeRunState(job);
27
+ if (job.run && isLanded(job))
28
+ return landedState(job);
29
+ if (job.run?.status === "ok" && job.run.gateDecision === "reject")
30
+ return "rejected";
31
+ if (job.run?.status === "suspended")
32
+ return "needs-human";
33
+ if (job.retriesExhausted)
34
+ return "dead";
35
+ if (job.runRetired || job.run?.status === "failed" || job.run?.status === "dead")
36
+ return "failed";
40
37
  if (job.launching)
41
38
  return "running";
42
39
  if (!dependenciesMet(job, observation))
43
40
  return "blocked";
44
- if (job.launchError)
41
+ if (job.launchError || job.launchFailed)
45
42
  return "failed";
46
43
  return "queued";
47
44
  }
45
+ /** State of the current run, before the attempt ledger retires it. */
46
+ function activeRunState(job) {
47
+ if (!job.run)
48
+ return "queued";
49
+ if (isLanded(job))
50
+ return landedState(job);
51
+ if (job.run.status === "ok" && job.run.gateDecision === "reject")
52
+ return "rejected";
53
+ if (job.run.status === "ok")
54
+ return "failed";
55
+ if (job.run.status === "failed")
56
+ return "failed";
57
+ if (job.run.status === "dead")
58
+ return "dead";
59
+ if (job.run.status === "suspended")
60
+ return "needs-human";
61
+ if (job.run.gateDecision === "approve")
62
+ return "approved";
63
+ return "running";
64
+ }
48
65
  /**
49
66
  * After-merged (the default) needs every upstream `done`.
50
67
  * Stacked needs its one upstream landed (approved and the run finished ok),
@@ -113,7 +130,24 @@ function canLaunch(job, observation) {
113
130
  return false;
114
131
  return dependenciesMet(job, observation);
115
132
  }
116
- export function decide(observation) {
133
+ /** A recorded transient failure whose backoff has elapsed. One retry per tick, ahead of a new job. */
134
+ function canRetry(job, observation, now) {
135
+ if (job.error || job.launching || job.retriesExhausted || job.ledgerError)
136
+ return false;
137
+ if (job.run && !job.runRetired)
138
+ return false;
139
+ if (!job.nextRetryAt)
140
+ return false;
141
+ const due = Date.parse(job.nextRetryAt);
142
+ if (!Number.isFinite(due) || due > now.getTime())
143
+ return false;
144
+ if (job.run?.gateDecision === "reject" || isLanded(job))
145
+ return false;
146
+ if (job.run?.status === "suspended")
147
+ return false;
148
+ return dependenciesMet(job, observation);
149
+ }
150
+ export function decide(observation, now = new Date()) {
117
151
  const actions = observation.breadcrumbsToClear.map((crumb) => ({
118
152
  type: "clear-breadcrumb",
119
153
  path: crumb.path,
@@ -132,6 +166,11 @@ export function decide(observation) {
132
166
  }
133
167
  if (inFlightCount(observation) >= observation.maxConcurrent)
134
168
  return actions;
169
+ const retry = observation.jobs.find((job) => canRetry(job, observation, now));
170
+ if (retry) {
171
+ actions.push({ type: "retry", jobId: retry.jobId, repo: retry.repo });
172
+ return actions;
173
+ }
135
174
  const ready = observation.jobs.filter((job) => canLaunch(job, observation));
136
175
  const healthy = ready.filter((job) => !job.launchError);
137
176
  const picked = healthy[0] ?? ready[0];