ai-foreman 1.6.2 → 1.7.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 +1 -1
- package/dist/adapters/claude.d.ts +5 -1
- package/dist/adapters/claude.js +41 -0
- package/dist/adapters/codex.d.ts +26 -28
- package/dist/adapters/codex.js +209 -148
- package/dist/adapters/recovering.d.ts +3 -1
- package/dist/adapters/recovering.js +2 -0
- package/dist/adapters/types.d.ts +29 -0
- package/dist/branch/planner.js +1 -1
- package/dist/branch/runner.d.ts +6 -0
- package/dist/branch/runner.js +149 -16
- package/dist/buildRuns.d.ts +9 -2
- package/dist/buildRuns.js +110 -7
- package/dist/cli/start.d.ts +4 -0
- package/dist/cli/start.js +283 -28
- package/dist/cli/status.js +25 -2
- package/dist/cli/tickets.d.ts +1 -0
- package/dist/cli/tickets.js +67 -27
- package/dist/foreman.d.ts +18 -3
- package/dist/foreman.js +112 -5
- package/dist/log.d.ts +1 -1
- package/dist/operationRecovery.d.ts +25 -0
- package/dist/operationRecovery.js +46 -0
- package/dist/outcomes.d.ts +16 -0
- package/dist/outcomes.js +27 -0
- package/dist/qaReview.d.ts +52 -0
- package/dist/qaReview.js +103 -0
- package/dist/qaSnapshot.d.ts +19 -0
- package/dist/qaSnapshot.js +88 -0
- package/dist/sessionLifecycle.d.ts +37 -0
- package/dist/sessionLifecycle.js +104 -0
- package/dist/statusReporter.d.ts +40 -0
- package/dist/statusReporter.js +31 -0
- package/dist/ticketPopulation.d.ts +47 -0
- package/dist/ticketPopulation.js +205 -0
- package/dist/tickets/blockers.d.ts +1 -1
- package/dist/tickets/blockers.js +2 -0
- package/dist/tickets/commands.d.ts +1 -0
- package/dist/tickets/commands.js +30 -1
- package/dist/tickets/delivery.d.ts +36 -0
- package/dist/tickets/delivery.js +204 -4
- package/dist/tickets/queue.d.ts +3 -0
- package/dist/tickets/queue.js +50 -1
- package/dist/tickets/stateDb.d.ts +2 -1
- package/dist/tickets/stateDb.js +24 -1
- package/dist/tickets/ticketSchema.d.ts +44 -0
- package/dist/tickets/ticketSchema.js +10 -0
- package/dist/tickets/validate.js +1 -1
- package/dist/workflowDb.d.ts +113 -0
- package/dist/workflowDb.js +261 -0
- package/package.json +16 -3
package/dist/branch/runner.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { BuilderAdapter, EffortLevel } from "../adapters/types.js";
|
|
2
|
+
import type { SessionStrategy } from "rafi-spec";
|
|
3
|
+
import { type QaNonconvergenceContext, type QaNonconvergenceDecision } from "../qaReview.js";
|
|
2
4
|
import type { Log } from "../log.js";
|
|
3
5
|
import type { BranchPlan, BranchPlanNode, BranchRunSummary, CompletionMode, MergeMethod, ReviewProvider } from "./types.js";
|
|
4
6
|
export interface DeliveryUnitSession {
|
|
@@ -39,7 +41,11 @@ export interface BranchRunnerOptions {
|
|
|
39
41
|
sessionId: string;
|
|
40
42
|
}>;
|
|
41
43
|
createBuilder: (cwd: string, sessionId?: string) => Promise<BuilderAdapter>;
|
|
44
|
+
createQa?: (cwd: string, sessionId?: string) => Promise<BuilderAdapter>;
|
|
45
|
+
builderSessionStrategy?: SessionStrategy;
|
|
46
|
+
qaSessionStrategy?: SessionStrategy;
|
|
42
47
|
observeBuilder?: (builder: BuilderAdapter) => Promise<void>;
|
|
48
|
+
qaNonconvergence?: (context: QaNonconvergenceContext) => Promise<QaNonconvergenceDecision>;
|
|
43
49
|
}
|
|
44
50
|
export declare function runBranchPlan(opts: BranchRunnerOptions): Promise<BranchRunSummary[]>;
|
|
45
51
|
export declare function buildBranchTicketInstruction(node: BranchPlanNode, trackerPaths?: {
|
package/dist/branch/runner.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { buildDurableQaFixHandoff, compactWithRetry, runIsolatedQa } from "../qaReview.js";
|
|
2
|
+
import { changeManifest } from "../qaSnapshot.js";
|
|
3
|
+
import { Foreman, MARKER_SPEC, parseStepStatus } from "../foreman.js";
|
|
2
4
|
import { fireNotification } from "../notify.js";
|
|
3
5
|
import { cmdBlock, cmdComplete, cmdUpdate } from "../tickets/commands.js";
|
|
4
6
|
import { commitAll, createTicketWorktree, findWorktreeForBranch, currentWorktreeBranch, deleteLocalBranch, ensureCleanBaseWorktree, ensureForemanExcluded, hasTrackerChanges, hasWorktreeChanges, headCommitIfAhead, removeTicketWorktree, mergeBranchToLocalBase, } from "./git.js";
|
|
@@ -6,6 +8,7 @@ import { checkGitHubPrMerged, createOrReusePr, enableGitHubAutoMerge, pushBranch
|
|
|
6
8
|
import { checkGitLabMrMerged, createOrReuseMr, enableGitLabAutoMerge, pushBranchForMr } from "./gitlab.js";
|
|
7
9
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
8
10
|
import { join } from "node:path";
|
|
11
|
+
import { WorkflowDb } from "../workflowDb.js";
|
|
9
12
|
export function readDeliveryUnitSession(projectDir, unitId) {
|
|
10
13
|
const path = deliverySessionPath(projectDir, unitId);
|
|
11
14
|
if (!existsSync(path))
|
|
@@ -29,6 +32,9 @@ export async function runBranchPlan(opts) {
|
|
|
29
32
|
const summaries = [];
|
|
30
33
|
const successfulBranches = new Set();
|
|
31
34
|
const pushedBranches = new Set();
|
|
35
|
+
let builderStreamSession;
|
|
36
|
+
let builderWorkSessions = 0;
|
|
37
|
+
const qaStream = { reviews: 0, modificationViolations: 0 };
|
|
32
38
|
for (const issue of opts.plan.issues) {
|
|
33
39
|
opts.log.write("branch-issue", { ...issue });
|
|
34
40
|
notifyIssue(opts.notificationsEnabled, issue.message);
|
|
@@ -102,6 +108,7 @@ export async function runBranchPlan(opts) {
|
|
|
102
108
|
base: node.baseBranch,
|
|
103
109
|
dependencies: node.dependencies,
|
|
104
110
|
});
|
|
111
|
+
workflowCheckpoint(opts.projectDir, opts.runId, "builder-before", node.ticket.id, { branch: node.branch, worktree: node.worktreePath });
|
|
105
112
|
const resumeSession = opts.resumeSessions?.get(node.ticket.id);
|
|
106
113
|
const worktreePath = resumeSession?.worktreePath
|
|
107
114
|
?? (sharedUnit ? findWorktreeForBranch(opts.projectDir, node.branch) : undefined)
|
|
@@ -119,16 +126,26 @@ export async function runBranchPlan(opts) {
|
|
|
119
126
|
sessionId: resumeSession.sessionId,
|
|
120
127
|
});
|
|
121
128
|
}
|
|
122
|
-
cmdUpdate(opts.projectDir, node.ticket.id, {
|
|
123
|
-
status: "in_progress",
|
|
124
|
-
actor: "foreman",
|
|
129
|
+
journalTracker(opts.projectDir, opts.runId, `${opts.runId}:tracker-update:${node.ticket.id}:in-progress`, { ticket: node.ticket.id, status: "in_progress" }, () => cmdUpdate(opts.projectDir, node.ticket.id, {
|
|
130
|
+
status: "in_progress", actor: "foreman",
|
|
125
131
|
summary: resumeSession ? `Resuming branch ${node.branch}` : `Starting branch ${node.branch}`,
|
|
126
|
-
});
|
|
127
|
-
|
|
132
|
+
}));
|
|
133
|
+
const continuedBuilderSession = resumeSession?.sessionId ?? (opts.builderSessionStrategy === "compact" ? builderStreamSession : undefined);
|
|
134
|
+
builder = await opts.createBuilder(worktreePath, continuedBuilderSession);
|
|
135
|
+
if (opts.builderSessionStrategy === "compact" && builderWorkSessions > 0 && continuedBuilderSession) {
|
|
136
|
+
const compacted = await compactWithRetry(builder);
|
|
137
|
+
if (!compacted.ok) {
|
|
138
|
+
await builder.close();
|
|
139
|
+
builder = await opts.createBuilder(worktreePath);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
builderWorkSessions += 1;
|
|
128
143
|
viewer = opts.observeBuilder?.(builder);
|
|
129
144
|
const foreman = new Foreman(builder, opts.log, opts.notificationsEnabled, opts.qaEnabled, 3, undefined);
|
|
130
145
|
const { result, status } = await foreman.runInstruction(resumeSession ? buildBranchTicketResumeInstruction(node, opts.trackerPaths) : buildBranchTicketInstruction(node, opts.trackerPaths));
|
|
146
|
+
workflowCheckpoint(opts.projectDir, opts.runId, "builder-after", node.ticket.id, { status: status.kind, sessionId: builder.sessionId(), worktree: worktreePath });
|
|
131
147
|
const sessionId = builder.sessionId();
|
|
148
|
+
builderStreamSession = opts.builderSessionStrategy === "compact" ? sessionId : undefined;
|
|
132
149
|
if (sessionId) {
|
|
133
150
|
opts.log.write("branch-session", {
|
|
134
151
|
ticket: node.ticket.id,
|
|
@@ -169,19 +186,51 @@ export async function runBranchPlan(opts) {
|
|
|
169
186
|
}
|
|
170
187
|
if (status.kind !== "done" && status.kind !== "plan_complete") {
|
|
171
188
|
const detail = status.reason ?? status.error ?? `builder emitted ${status.kind}`;
|
|
172
|
-
cmdBlock(opts.projectDir, node.ticket.id, { summary: detail, actor: "foreman" });
|
|
189
|
+
journalTracker(opts.projectDir, opts.runId, `${opts.runId}:tracker-update:${node.ticket.id}:builder-block`, { ticket: node.ticket.id, status: "blocked", detail }, () => cmdBlock(opts.projectDir, node.ticket.id, { summary: detail, actor: "foreman" }));
|
|
173
190
|
summaries.push(summaryFor(node, status.kind === "blocked" ? "blocked" : "needs-human", detail));
|
|
174
191
|
continue;
|
|
175
192
|
}
|
|
176
193
|
let qaSummary;
|
|
194
|
+
let qaWaived = false;
|
|
177
195
|
if (opts.qaEnabled) {
|
|
178
|
-
|
|
179
|
-
|
|
196
|
+
if (!opts.createQa)
|
|
197
|
+
throw new Error("independent disposable QA factory is required when QA is enabled");
|
|
198
|
+
workflowCheckpoint(opts.projectDir, opts.runId, "qa-before", node.ticket.id, { worktree: worktreePath });
|
|
199
|
+
const qa = await runIsolatedQa({
|
|
200
|
+
ticket: node.ticket, builderWorktree: worktreePath, builderSummary: status.summary ?? result.text,
|
|
201
|
+
qaStrategy: opts.qaSessionStrategy ?? "compact", state: qaStream, createQa: opts.createQa, maxCycles: 3,
|
|
202
|
+
evidence: (entry) => opts.log.write("qa-evidence", { ticket: node.ticket.id, ...entry }),
|
|
203
|
+
fix: async (issues) => {
|
|
204
|
+
if (!builder)
|
|
205
|
+
return { ok: false, detail: "Builder session unavailable" };
|
|
206
|
+
builderWorkSessions += 1;
|
|
207
|
+
if (opts.builderSessionStrategy === "compact" && builder.sessionId()) {
|
|
208
|
+
const compacted = await compactWithRetry(builder);
|
|
209
|
+
if (!compacted.ok) {
|
|
210
|
+
await builder.close();
|
|
211
|
+
builder = await opts.createBuilder(worktreePath);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
else if (opts.builderSessionStrategy === "fresh") {
|
|
215
|
+
await builder.close();
|
|
216
|
+
builder = await opts.createBuilder(worktreePath);
|
|
217
|
+
}
|
|
218
|
+
const digest = changeManifest(worktreePath).diffDigest;
|
|
219
|
+
const fixed = await builder.sendTurn(buildDurableQaFixHandoff(node.ticket, issues, worktreePath, result.text, digest));
|
|
220
|
+
const fixedStatus = parseStepStatus(fixed.text);
|
|
221
|
+
builderStreamSession = opts.builderSessionStrategy === "compact" ? builder.sessionId() : undefined;
|
|
222
|
+
return { ok: !fixed.isError && fixedStatus.kind === "done", detail: fixedStatus.error ?? fixed.text.slice(0, 500) };
|
|
223
|
+
},
|
|
224
|
+
onNonconvergence: opts.qaNonconvergence,
|
|
225
|
+
});
|
|
226
|
+
workflowCheckpoint(opts.projectDir, opts.runId, "qa-after", node.ticket.id, { outcome: qa.outcome, detail: qa.detail });
|
|
227
|
+
if (qa.outcome !== "passed" && qa.outcome !== "waived") {
|
|
180
228
|
const detail = qa.detail ?? "QA did not pass";
|
|
181
|
-
cmdBlock(opts.projectDir, node.ticket.id, { summary: detail, actor: "foreman" });
|
|
229
|
+
journalTracker(opts.projectDir, opts.runId, `${opts.runId}:tracker-update:${node.ticket.id}:qa-block`, { ticket: node.ticket.id, status: "blocked", detail }, () => cmdBlock(opts.projectDir, node.ticket.id, { summary: detail, actor: "foreman" }));
|
|
182
230
|
summaries.push(summaryFor(node, qa.outcome === "blocked" ? "blocked" : "needs-human", detail));
|
|
183
231
|
continue;
|
|
184
232
|
}
|
|
233
|
+
qaWaived = qa.outcome === "waived";
|
|
185
234
|
qaSummary = qa.summary;
|
|
186
235
|
}
|
|
187
236
|
else {
|
|
@@ -203,7 +252,18 @@ export async function runBranchPlan(opts) {
|
|
|
203
252
|
}
|
|
204
253
|
let commit;
|
|
205
254
|
if (hasWorktreeChanges(worktreePath)) {
|
|
206
|
-
|
|
255
|
+
workflowCheckpoint(opts.projectDir, opts.runId, "commit-before", node.ticket.id, { branch: node.branch });
|
|
256
|
+
const operation = `${opts.runId}:commit:${node.ticket.id}`;
|
|
257
|
+
planJournal(opts.projectDir, opts.runId, operation, "commit", { ticket: node.ticket.id, branch: node.branch, worktreePath });
|
|
258
|
+
try {
|
|
259
|
+
commit = commitAll(worktreePath, `${node.ticket.id}: ${node.ticket.title}`);
|
|
260
|
+
confirmJournal(opts.projectDir, operation, commit, { sha: commit });
|
|
261
|
+
workflowCheckpoint(opts.projectDir, opts.runId, "commit-after", node.ticket.id, { sha: commit });
|
|
262
|
+
}
|
|
263
|
+
catch (error) {
|
|
264
|
+
failJournal(opts.projectDir, operation, error, false);
|
|
265
|
+
throw error;
|
|
266
|
+
}
|
|
207
267
|
}
|
|
208
268
|
if (!commit && (createsReviewForNode || (completionMode === "direct-merge" && completesSharedUnit))) {
|
|
209
269
|
commit = headCommitIfAhead(worktreePath, node.baseBranch);
|
|
@@ -211,14 +271,20 @@ export async function runBranchPlan(opts) {
|
|
|
211
271
|
const summary = summaryFor(node, "done", commit ? undefined : "no_changes");
|
|
212
272
|
summary.commit = commit;
|
|
213
273
|
if (commit && createsReviewForNode) {
|
|
274
|
+
workflowCheckpoint(opts.projectDir, opts.runId, "push-before", node.ticket.id, { branch: node.branch, sha: commit });
|
|
275
|
+
const pushOperation = `${opts.runId}:push:${node.branch}`;
|
|
276
|
+
planJournal(opts.projectDir, opts.runId, pushOperation, "push", { branch: node.branch, sha: commit, remote: "origin" });
|
|
214
277
|
const push = reviewProvider === "gitlab"
|
|
215
278
|
? pushBranchForMr(worktreePath, node.branch)
|
|
216
279
|
: pushBranchForPr(worktreePath, node.branch);
|
|
217
280
|
if (push.ok) {
|
|
281
|
+
confirmJournal(opts.projectDir, pushOperation, node.branch, { sha: commit });
|
|
218
282
|
opts.log.write("branch-push", { ticket: node.ticket.id, branch: node.branch, status: "pushed" });
|
|
219
283
|
summary.pushStatus = "pushed";
|
|
284
|
+
workflowCheckpoint(opts.projectDir, opts.runId, "push-after", node.ticket.id, { branch: node.branch, sha: commit });
|
|
220
285
|
}
|
|
221
286
|
else {
|
|
287
|
+
failJournal(opts.projectDir, pushOperation, push.message, push.code === "network_or_timeout");
|
|
222
288
|
summary.pushStatus = "failed";
|
|
223
289
|
summary.pr = {
|
|
224
290
|
status: "skipped",
|
|
@@ -244,6 +310,9 @@ export async function runBranchPlan(opts) {
|
|
|
244
310
|
continue;
|
|
245
311
|
}
|
|
246
312
|
pushedBranches.add(node.branch);
|
|
313
|
+
workflowCheckpoint(opts.projectDir, opts.runId, "review-creation-before", node.ticket.id, { provider: reviewProvider, head: node.branch, base: node.baseBranch });
|
|
314
|
+
const reviewOperation = `${opts.runId}:${reviewProvider === "gitlab" ? "mr" : "pr"}:${node.branch}:${node.baseBranch}`;
|
|
315
|
+
planJournal(opts.projectDir, opts.runId, reviewOperation, reviewProvider === "gitlab" ? "mr-create" : "pr-create", { head: node.branch, base: node.baseBranch, sha: commit });
|
|
247
316
|
const pr = reviewProvider === "gitlab"
|
|
248
317
|
? createOrReuseMr(opts.projectDir, {
|
|
249
318
|
node,
|
|
@@ -263,6 +332,12 @@ export async function runBranchPlan(opts) {
|
|
|
263
332
|
commit,
|
|
264
333
|
});
|
|
265
334
|
summary.pr = pr;
|
|
335
|
+
if (pr.status === "created" || pr.status === "existing") {
|
|
336
|
+
confirmJournal(opts.projectDir, reviewOperation, pr.url, { head: node.branch, base: node.baseBranch, url: pr.url });
|
|
337
|
+
workflowCheckpoint(opts.projectDir, opts.runId, "review-creation-after", node.ticket.id, { provider: reviewProvider, head: node.branch, base: node.baseBranch, url: pr.url });
|
|
338
|
+
}
|
|
339
|
+
else if (pr.status === "failed")
|
|
340
|
+
failJournal(opts.projectDir, reviewOperation, pr.message ?? pr.error ?? "review creation failed", pr.code === "network_or_timeout");
|
|
266
341
|
if (pr.status === "created")
|
|
267
342
|
opts.log.write(reviewProvider === "gitlab" ? "mr-created" : "pr-created", { ticket: node.ticket.id, branch: node.branch, url: pr.url });
|
|
268
343
|
if (pr.status === "existing")
|
|
@@ -314,13 +389,18 @@ export async function runBranchPlan(opts) {
|
|
|
314
389
|
if (opts.cleanupBranches ?? true)
|
|
315
390
|
deleteLocalBranch(opts.projectDir, node.branch);
|
|
316
391
|
}
|
|
392
|
+
const completionOperation = `${opts.runId}:ticket-complete:${node.ticket.id}`;
|
|
393
|
+
workflowCheckpoint(opts.projectDir, opts.runId, "ticket-completion-before", node.ticket.id, { validationResult: qaWaived ? "failed" : opts.qaEnabled ? "passed" : "not_applicable" });
|
|
394
|
+
planJournal(opts.projectDir, opts.runId, completionOperation, "ticket-complete", { ticket: node.ticket.id });
|
|
317
395
|
cmdComplete(opts.projectDir, node.ticket.id, {
|
|
318
396
|
actor: "foreman",
|
|
319
397
|
summary: status.summary ?? `Completed ${node.ticket.id}`,
|
|
320
|
-
validationResult: opts.qaEnabled ? "passed" : "not_applicable",
|
|
321
|
-
validationNotes: opts.qaEnabled ? "Foreman QA emitted qa_pass" : "Foreman QA disabled for this run",
|
|
322
|
-
evidence: opts.qaEnabled ? (qaSummary ?? "Foreman QA emitted qa_pass") : undefined,
|
|
398
|
+
validationResult: qaWaived ? "failed" : opts.qaEnabled ? "passed" : "not_applicable",
|
|
399
|
+
validationNotes: qaWaived ? "User explicitly waived unresolved QA failures" : opts.qaEnabled ? "Foreman QA emitted qa_pass" : "Foreman QA disabled for this run",
|
|
400
|
+
evidence: opts.qaEnabled ? (qaSummary ?? (qaWaived ? "Unresolved QA issues preserved in run evidence" : "Foreman QA emitted qa_pass")) : undefined,
|
|
323
401
|
});
|
|
402
|
+
confirmJournal(opts.projectDir, completionOperation, node.ticket.id, { status: "done" });
|
|
403
|
+
workflowCheckpoint(opts.projectDir, opts.runId, "ticket-completion-after", node.ticket.id, { status: "done" });
|
|
324
404
|
successfulBranches.add(node.ticket.id);
|
|
325
405
|
summaries.push(summary);
|
|
326
406
|
opts.log.write("branch-complete", {
|
|
@@ -344,7 +424,7 @@ export async function runBranchPlan(opts) {
|
|
|
344
424
|
: "builder_error";
|
|
345
425
|
opts.log.write("branch-issue", { ticket: node.ticket.id, code, message, blocking: false });
|
|
346
426
|
notifyIssue(opts.notificationsEnabled, `${node.ticket.id}: ${message}`);
|
|
347
|
-
cmdBlock(opts.projectDir, node.ticket.id, { summary: message, actor: "foreman" });
|
|
427
|
+
journalTracker(opts.projectDir, opts.runId, `${opts.runId}:tracker-update:${node.ticket.id}:runtime-block`, { ticket: node.ticket.id, status: "blocked", detail: message }, () => cmdBlock(opts.projectDir, node.ticket.id, { summary: message, actor: "foreman" }));
|
|
348
428
|
summaries.push(summaryFor(node, "blocked", message));
|
|
349
429
|
}
|
|
350
430
|
finally {
|
|
@@ -354,6 +434,59 @@ export async function runBranchPlan(opts) {
|
|
|
354
434
|
}
|
|
355
435
|
return summaries;
|
|
356
436
|
}
|
|
437
|
+
function planJournal(projectDir, runId, key, kind, intent) {
|
|
438
|
+
const db = new WorkflowDb(projectDir);
|
|
439
|
+
try {
|
|
440
|
+
if (!db.getRun(runId))
|
|
441
|
+
db.createRun({ runId, kind: "build", originalWork: {}, state: {} });
|
|
442
|
+
db.planOperation({ runId, idempotencyKey: key, kind, intent });
|
|
443
|
+
db.updateOperation(key, "in_progress");
|
|
444
|
+
}
|
|
445
|
+
finally {
|
|
446
|
+
db.close();
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
function confirmJournal(projectDir, key, externalId, result) {
|
|
450
|
+
const db = new WorkflowDb(projectDir);
|
|
451
|
+
try {
|
|
452
|
+
db.updateOperation(key, "confirmed", { externalId, result });
|
|
453
|
+
}
|
|
454
|
+
finally {
|
|
455
|
+
db.close();
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
function failJournal(projectDir, key, error, uncertain) {
|
|
459
|
+
const db = new WorkflowDb(projectDir);
|
|
460
|
+
try {
|
|
461
|
+
db.updateOperation(key, uncertain ? "uncertain" : "failed", { error: error instanceof Error ? error.message : String(error) });
|
|
462
|
+
}
|
|
463
|
+
finally {
|
|
464
|
+
db.close();
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
function journalTracker(projectDir, runId, key, intent, action) {
|
|
468
|
+
planJournal(projectDir, runId, key, "tracker-update", intent);
|
|
469
|
+
try {
|
|
470
|
+
action();
|
|
471
|
+
confirmJournal(projectDir, key, undefined, intent);
|
|
472
|
+
}
|
|
473
|
+
catch (error) {
|
|
474
|
+
failJournal(projectDir, key, error, false);
|
|
475
|
+
throw error;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
function workflowCheckpoint(projectDir, runId, checkpoint, ticket, payload) {
|
|
479
|
+
const db = new WorkflowDb(projectDir);
|
|
480
|
+
try {
|
|
481
|
+
const run = db.getRun(runId);
|
|
482
|
+
if (!run)
|
|
483
|
+
return;
|
|
484
|
+
db.transition(runId, { checkpoint, state: { ...run.state, currentTicket: ticket, ...payload }, event: checkpoint, payload: { ticket, ...payload } });
|
|
485
|
+
}
|
|
486
|
+
finally {
|
|
487
|
+
db.close();
|
|
488
|
+
}
|
|
489
|
+
}
|
|
357
490
|
async function waitForAutoMergeDependencies(opts, node, provider) {
|
|
358
491
|
const dependencyNodes = node.dependencies
|
|
359
492
|
.map((dep) => opts.plan.nodes.find((candidate) => candidate.ticket.id === dep))
|
|
@@ -435,7 +568,7 @@ function blockBranchIssue(opts, node, summary, code, message, details) {
|
|
|
435
568
|
output: details?.output,
|
|
436
569
|
});
|
|
437
570
|
notifyIssue(opts.notificationsEnabled, `${node.ticket.id}: ${message}`);
|
|
438
|
-
cmdBlock(opts.projectDir, node.ticket.id, { summary: message, actor: "foreman" });
|
|
571
|
+
journalTracker(opts.projectDir, opts.runId, `${opts.runId}:tracker-update:${node.ticket.id}:remote-block`, { ticket: node.ticket.id, status: "blocked", detail: message }, () => cmdBlock(opts.projectDir, node.ticket.id, { summary: message, actor: "foreman" }));
|
|
439
572
|
}
|
|
440
573
|
function failureLogFields(node, pr) {
|
|
441
574
|
return {
|
package/dist/buildRuns.d.ts
CHANGED
|
@@ -12,11 +12,17 @@ export interface CreateBuildRunInput {
|
|
|
12
12
|
branch?: string;
|
|
13
13
|
baseHead?: string;
|
|
14
14
|
startHead?: string;
|
|
15
|
-
builder?:
|
|
16
|
-
qa?:
|
|
15
|
+
builder?: LegacyResolvedAgentSettings;
|
|
16
|
+
qa?: LegacyResolvedAgentSettings;
|
|
17
17
|
now?: Date;
|
|
18
18
|
}
|
|
19
19
|
export declare function createBuildRun(input: CreateBuildRunInput): BuildRunRecordV1;
|
|
20
|
+
export declare function resumeBuildRun(projectDir: string, runId: string, patch: {
|
|
21
|
+
builder?: LegacyResolvedAgentSettings;
|
|
22
|
+
qa?: LegacyResolvedAgentSettings;
|
|
23
|
+
builderSessionId?: string | null;
|
|
24
|
+
}, now?: Date): BuildRunRecordV1;
|
|
25
|
+
type LegacyResolvedAgentSettings = Omit<ResolvedAgentSettings, "session_strategy" | "settings_revision"> & Partial<Pick<ResolvedAgentSettings, "session_strategy" | "settings_revision">>;
|
|
20
26
|
export declare function saveBuildRun(projectDir: string, run: BuildRunRecordV1, now?: Date): BuildRunRecordV1;
|
|
21
27
|
export declare function checkpointBuildRun(projectDir: string, run: BuildRunRecordV1, checkpoint: string, patch?: Partial<BuildRunRecordV1>): BuildRunRecordV1;
|
|
22
28
|
export declare function persistBuildSession(projectDir: string, run: BuildRunRecordV1, role: "builder" | "qa", sessionId: string): BuildRunRecordV1;
|
|
@@ -33,3 +39,4 @@ export declare function recoverableBuildRuns(projectDir: string, now?: Date): Ar
|
|
|
33
39
|
}>;
|
|
34
40
|
export declare function isLeaseActive(run: BuildRunRecordV1, now?: Date): boolean;
|
|
35
41
|
export declare function buildRecoveryPreview(run: BuildRunRecordV1): string[];
|
|
42
|
+
export {};
|
package/dist/buildRuns.js
CHANGED
|
@@ -2,6 +2,7 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { hostname } from "node:os";
|
|
4
4
|
import { join, resolve } from "node:path";
|
|
5
|
+
import { WorkflowDb } from "./workflowDb.js";
|
|
5
6
|
export const BUILD_RUN_VERSION = 1;
|
|
6
7
|
export const BUILD_RUN_DIRECTORY = ".foreman/runs";
|
|
7
8
|
export const BUILD_HEARTBEAT_MS = 10_000;
|
|
@@ -18,8 +19,8 @@ export function createBuildRun(input) {
|
|
|
18
19
|
branchMode: input.branchMode ?? "current",
|
|
19
20
|
checkpoint: "created",
|
|
20
21
|
currentTicket: input.tickets[0],
|
|
21
|
-
builder: input.builder ? { settings: input.builder } : undefined,
|
|
22
|
-
qa: input.qa ? { settings: input.qa } : undefined,
|
|
22
|
+
builder: input.builder ? { settings: normalizeCapturedSettings(input.builder) } : undefined,
|
|
23
|
+
qa: input.qa ? { settings: normalizeCapturedSettings(input.qa) } : undefined,
|
|
23
24
|
repository: {
|
|
24
25
|
root: resolve(input.repositoryRoot),
|
|
25
26
|
worktree: resolve(input.worktree ?? input.repositoryRoot),
|
|
@@ -33,7 +34,38 @@ export function createBuildRun(input) {
|
|
|
33
34
|
createdAt: stamp,
|
|
34
35
|
updatedAt: stamp,
|
|
35
36
|
};
|
|
36
|
-
|
|
37
|
+
const saved = saveBuildRun(input.repositoryRoot, run, now);
|
|
38
|
+
const workflow = new WorkflowDb(input.repositoryRoot);
|
|
39
|
+
try {
|
|
40
|
+
workflow.acquireLease(saved.runId, undefined, now, BUILD_LEASE_STALE_MS);
|
|
41
|
+
}
|
|
42
|
+
finally {
|
|
43
|
+
workflow.close();
|
|
44
|
+
}
|
|
45
|
+
return saved;
|
|
46
|
+
}
|
|
47
|
+
export function resumeBuildRun(projectDir, runId, patch, now = new Date()) {
|
|
48
|
+
const existing = readBuildRuns(projectDir).find((run) => run.runId === runId);
|
|
49
|
+
if (!existing)
|
|
50
|
+
throw new Error(`recoverable build run not found: ${runId}`);
|
|
51
|
+
if (existing.status === "completed")
|
|
52
|
+
throw new Error(`build run ${runId} is already complete`);
|
|
53
|
+
const workflow = new WorkflowDb(projectDir);
|
|
54
|
+
try {
|
|
55
|
+
workflow.acquireLease(runId, undefined, now, BUILD_LEASE_STALE_MS);
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
workflow.close();
|
|
59
|
+
}
|
|
60
|
+
return saveBuildRun(projectDir, {
|
|
61
|
+
...existing, status: "running", checkpoint: "recovery-resumed", completedAt: undefined,
|
|
62
|
+
builder: patch.builder ? { settings: normalizeCapturedSettings(patch.builder), ...(patch.builderSessionId === null ? {} : { sessionId: patch.builderSessionId ?? existing.builder?.sessionId }) } : existing.builder,
|
|
63
|
+
qa: patch.qa ? { settings: normalizeCapturedSettings(patch.qa), sessionId: existing.qa?.sessionId } : existing.qa,
|
|
64
|
+
lease: currentLease(now),
|
|
65
|
+
}, now);
|
|
66
|
+
}
|
|
67
|
+
function normalizeCapturedSettings(settings) {
|
|
68
|
+
return { ...settings, session_strategy: settings.session_strategy ?? (["builder", "qa", "ticket-maker"].includes(settings.role) ? "compact" : "fresh"), settings_revision: settings.settings_revision ?? 0 };
|
|
37
69
|
}
|
|
38
70
|
export function saveBuildRun(projectDir, run, now = new Date()) {
|
|
39
71
|
validateBuildRun(run);
|
|
@@ -44,6 +76,28 @@ export function saveBuildRun(projectDir, run, now = new Date()) {
|
|
|
44
76
|
const temp = `${target}.${process.pid}.${randomUUID()}.tmp`;
|
|
45
77
|
writeFileSync(temp, `${JSON.stringify(next, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
46
78
|
renameSync(temp, target);
|
|
79
|
+
const workflow = new WorkflowDb(projectDir);
|
|
80
|
+
try {
|
|
81
|
+
const existing = workflow.getRun(next.runId);
|
|
82
|
+
if (!existing)
|
|
83
|
+
workflow.createRun({
|
|
84
|
+
runId: next.runId, kind: "build", checkpoint: next.checkpoint,
|
|
85
|
+
originalWork: { tickets: next.tickets, deliveryUnit: next.deliveryUnit, branchMode: next.branchMode },
|
|
86
|
+
remainingWork: { tickets: remainingTickets(next) }, state: next,
|
|
87
|
+
}, now);
|
|
88
|
+
else
|
|
89
|
+
workflow.transition(next.runId, {
|
|
90
|
+
status: workflowStatus(next.status), checkpoint: next.checkpoint,
|
|
91
|
+
remainingWork: { tickets: remainingTickets(next) }, state: next, event: "build_snapshot",
|
|
92
|
+
}, now);
|
|
93
|
+
if (next.builder?.sessionId)
|
|
94
|
+
workflow.recordSession(next.runId, "builder", "builder", next.builder.sessionId, "checkpoint", next.builder.settings, now);
|
|
95
|
+
if (next.qa?.sessionId)
|
|
96
|
+
workflow.recordSession(next.runId, "qa", "qa", next.qa.sessionId, "checkpoint", next.qa.settings, now);
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
workflow.close();
|
|
100
|
+
}
|
|
47
101
|
return next;
|
|
48
102
|
}
|
|
49
103
|
export function checkpointBuildRun(projectDir, run, checkpoint, patch = {}) {
|
|
@@ -58,19 +112,42 @@ export function persistBuildSession(projectDir, run, role, sessionId) {
|
|
|
58
112
|
export function recordBuildReceipt(projectDir, run, operationId, detail) {
|
|
59
113
|
if (run.receipts[operationId])
|
|
60
114
|
return run;
|
|
61
|
-
|
|
115
|
+
const next = saveBuildRun(projectDir, {
|
|
62
116
|
...run,
|
|
63
117
|
receipts: { ...run.receipts, [operationId]: { completedAt: new Date().toISOString(), ...detail } },
|
|
64
118
|
});
|
|
119
|
+
const workflow = new WorkflowDb(projectDir);
|
|
120
|
+
try {
|
|
121
|
+
workflow.planOperation({ runId: run.runId, idempotencyKey: operationId, kind: operationId.split(":", 1)[0] ?? "operation", intent: { checkpoint: run.checkpoint } });
|
|
122
|
+
workflow.updateOperation(operationId, "confirmed", { externalId: detail?.externalId, result: { detail: detail?.detail } });
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
workflow.close();
|
|
126
|
+
}
|
|
127
|
+
return next;
|
|
65
128
|
}
|
|
66
129
|
export function heartbeatBuildRun(projectDir, run, now = new Date()) {
|
|
67
|
-
|
|
130
|
+
const saved = saveBuildRun(projectDir, { ...run, lease: { ...(run.lease ?? currentLease(now)), heartbeatAt: now.toISOString() } }, now);
|
|
131
|
+
const workflow = new WorkflowDb(projectDir);
|
|
132
|
+
try {
|
|
133
|
+
const lease = workflow.currentLease();
|
|
134
|
+
if (lease?.runId === run.runId)
|
|
135
|
+
workflow.heartbeatLease(lease, now);
|
|
136
|
+
}
|
|
137
|
+
finally {
|
|
138
|
+
workflow.close();
|
|
139
|
+
}
|
|
140
|
+
return saved;
|
|
68
141
|
}
|
|
69
142
|
export function releaseBuildLease(projectDir, run, status = "interrupted") {
|
|
70
|
-
|
|
143
|
+
const saved = saveBuildRun(projectDir, { ...run, status, lease: undefined });
|
|
144
|
+
releaseWorkflowLease(projectDir, run.runId);
|
|
145
|
+
return saved;
|
|
71
146
|
}
|
|
72
147
|
export function completeBuildRun(projectDir, run, now = new Date()) {
|
|
73
|
-
|
|
148
|
+
const saved = saveBuildRun(projectDir, { ...run, status: "completed", checkpoint: "complete", lease: undefined, completedAt: now.toISOString() }, now);
|
|
149
|
+
releaseWorkflowLease(projectDir, run.runId, now);
|
|
150
|
+
return saved;
|
|
74
151
|
}
|
|
75
152
|
export function readBuildRuns(projectDir) {
|
|
76
153
|
const directory = join(resolve(projectDir), BUILD_RUN_DIRECTORY);
|
|
@@ -147,3 +224,29 @@ function validateBuildRun(run) {
|
|
|
147
224
|
throw new Error("invalid build run record");
|
|
148
225
|
}
|
|
149
226
|
}
|
|
227
|
+
function workflowStatus(status) {
|
|
228
|
+
if (status === "completed")
|
|
229
|
+
return "completed";
|
|
230
|
+
if (status === "failed")
|
|
231
|
+
return "failed";
|
|
232
|
+
if (status === "blocked")
|
|
233
|
+
return "blocked";
|
|
234
|
+
if (status === "interrupted" || status === "recoverable")
|
|
235
|
+
return "paused";
|
|
236
|
+
return "running";
|
|
237
|
+
}
|
|
238
|
+
function remainingTickets(run) {
|
|
239
|
+
const current = run.currentTicket ? run.tickets.indexOf(run.currentTicket) : 0;
|
|
240
|
+
return run.status === "completed" ? [] : run.tickets.slice(Math.max(0, current));
|
|
241
|
+
}
|
|
242
|
+
function releaseWorkflowLease(projectDir, runId, now = new Date()) {
|
|
243
|
+
const workflow = new WorkflowDb(projectDir);
|
|
244
|
+
try {
|
|
245
|
+
const lease = workflow.currentLease();
|
|
246
|
+
if (lease?.runId === runId)
|
|
247
|
+
workflow.releaseLease(lease, now);
|
|
248
|
+
}
|
|
249
|
+
finally {
|
|
250
|
+
workflow.close();
|
|
251
|
+
}
|
|
252
|
+
}
|
package/dist/cli/start.d.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
|
+
import type { BranchPlan } from "../branch/types.js";
|
|
3
|
+
import { type DeliveryConfig, type DeliveryStack } from "../tickets/delivery.js";
|
|
2
4
|
export declare function formatStartResumeCommand(executable: "ai-foreman", projectDir: string, steps: number, sessionId: string): string;
|
|
3
5
|
export declare function formatResumeGuidance(executable: "rafi" | "ai-foreman", projectDir: string, steps: number, sessionId?: string): string[];
|
|
4
6
|
export declare function buildStartCommand(): Command;
|
|
7
|
+
/** Make the executable branch plan exactly match each explicit root-to-tip stack. */
|
|
8
|
+
export declare function applyExplicitStackTopology(plan: BranchPlan, stacks: DeliveryStack[], delivery: DeliveryConfig, branchPrefix: string): BranchPlan;
|