ai-foreman 1.7.18 → 1.7.19
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 +18 -7
- package/dist/activity.d.ts +11 -1
- package/dist/activity.js +70 -15
- package/dist/adapters/claude.d.ts +11 -1
- package/dist/adapters/claude.js +122 -18
- package/dist/adapters/codex.d.ts +13 -1
- package/dist/adapters/codex.js +148 -31
- package/dist/adapters/recovering.d.ts +3 -1
- package/dist/adapters/recovering.js +12 -4
- package/dist/adapters/types.d.ts +27 -1
- package/dist/agentRun.js +2 -0
- package/dist/branch/currentGuard.d.ts +3 -1
- package/dist/branch/currentGuard.js +11 -2
- package/dist/branch/resume.js +1 -1
- package/dist/branch/runner.d.ts +2 -0
- package/dist/branch/runner.js +90 -11
- package/dist/buildApproval.d.ts +9 -0
- package/dist/buildApproval.js +126 -0
- package/dist/buildRuns.js +70 -32
- package/dist/cli/recovery.js +9 -13
- package/dist/cli/runtimeAuthPrompt.d.ts +7 -0
- package/dist/cli/runtimeAuthPrompt.js +40 -5
- package/dist/cli/start.d.ts +16 -1
- package/dist/cli/start.js +159 -79
- package/dist/cli/tickets.d.ts +11 -0
- package/dist/cli/tickets.js +382 -145
- package/dist/continuity.d.ts +7 -1
- package/dist/continuity.js +86 -7
- package/dist/diagnostics.js +35 -1
- package/dist/foreman.d.ts +8 -1
- package/dist/foreman.js +144 -29
- package/dist/handoffs.js +121 -79
- package/dist/humanDecision.d.ts +18 -0
- package/dist/humanDecision.js +43 -0
- package/dist/index.js +2 -1
- package/dist/log.d.ts +1 -1
- package/dist/managerPacket.js +1 -0
- package/dist/observability.js +2 -1
- package/dist/providerQuestions.d.ts +5 -0
- package/dist/providerQuestions.js +24 -3
- package/dist/qaDeliveryJournal.d.ts +70 -0
- package/dist/qaDeliveryJournal.js +1 -0
- package/dist/qaFailureDelivery.d.ts +26 -10
- package/dist/qaFailureDelivery.js +399 -412
- package/dist/qaHandbackHistory.d.ts +4 -0
- package/dist/qaHandbackHistory.js +30 -0
- package/dist/qaHandbackMigration.d.ts +9 -0
- package/dist/qaHandbackMigration.js +147 -0
- package/dist/qaPrerequisites.d.ts +26 -0
- package/dist/qaPrerequisites.js +61 -0
- package/dist/qaProtocolV2.d.ts +8 -0
- package/dist/qaProtocolV2.js +3 -3
- package/dist/qaReview.d.ts +8 -0
- package/dist/qaReview.js +54 -10
- package/dist/questionTrace.d.ts +31 -0
- package/dist/questionTrace.js +85 -0
- package/dist/readOnlyProposal.d.ts +8 -0
- package/dist/readOnlyProposal.js +41 -0
- package/dist/recoveryPolicy.js +14 -3
- package/dist/runtimeAuth.d.ts +2 -1
- package/dist/runtimeAuth.js +2 -2
- package/dist/runtimeReadiness.d.ts +13 -1
- package/dist/runtimeReadiness.js +69 -11
- package/dist/sessionLifecycle.d.ts +1 -0
- package/dist/sessionLifecycle.js +53 -3
- package/dist/sources/sourceRegistry.d.ts +7 -0
- package/dist/sources/sourceRegistry.js +14 -0
- package/dist/stateTransfer.js +2 -0
- package/dist/statusReporter.d.ts +3 -1
- package/dist/statusReporter.js +8 -2
- package/dist/supervisedStart.d.ts +12 -0
- package/dist/supervisedStart.js +276 -0
- package/dist/supervisor.js +44 -22
- package/dist/ticketPopulation.js +1 -0
- package/dist/tickets/setupConfig.d.ts +8 -0
- package/dist/tickets/setupConfig.js +38 -4
- package/dist/util/deadline.d.ts +13 -0
- package/dist/util/deadline.js +60 -0
- package/dist/workflowDb.d.ts +55 -11
- package/dist/workflowDb.js +288 -63
- package/dist/workflowReader.d.ts +10 -2
- package/dist/workflowReader.js +35 -1
- package/package.json +3 -3
package/dist/branch/runner.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { WorkflowReader } from "../workflowReader.js";
|
|
2
|
+
import { buildScopeRevision } from "../buildApproval.js";
|
|
1
3
|
import { beginQaFinalization, completeQaFinalization, verifyPendingQaFinalizationSource, compactWithRetry, runIsolatedQa } from "../qaReview.js";
|
|
2
4
|
import { QaFailureDeliveryService } from "../qaFailureDelivery.js";
|
|
3
5
|
import { Foreman, MARKER_SPEC } from "../foreman.js";
|
|
@@ -20,7 +22,20 @@ export function readDeliveryUnitSession(projectDir, unitId) {
|
|
|
20
22
|
if (!existsSync(path))
|
|
21
23
|
return undefined;
|
|
22
24
|
try {
|
|
23
|
-
|
|
25
|
+
const cached = JSON.parse(readFileSync(path, "utf8"));
|
|
26
|
+
const reader = new WorkflowReader(projectDir);
|
|
27
|
+
try {
|
|
28
|
+
const known = reader.branchResumeSessions(false);
|
|
29
|
+
const active = reader.branchResumeSessions().filter(row => row.deliveryUnitId === unitId && row.worktreePath === cached.worktreePath && row.branch === cached.branch).at(-1);
|
|
30
|
+
if (active)
|
|
31
|
+
return { ...cached, sessionId: active.sessionId, sessionRef: active.sessionRef, ticket: active.ticket };
|
|
32
|
+
if (known.some(row => row.deliveryUnitId === unitId) || reader.buildRuns().length || (cached.runId && reader.getRun(cached.runId)))
|
|
33
|
+
return undefined;
|
|
34
|
+
return cached;
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
reader.close();
|
|
38
|
+
}
|
|
24
39
|
}
|
|
25
40
|
catch {
|
|
26
41
|
return undefined;
|
|
@@ -108,7 +123,40 @@ export async function runBranchPlan(opts) {
|
|
|
108
123
|
}
|
|
109
124
|
if (opts.plan.issues.some((issue) => issue.blocking))
|
|
110
125
|
return summaries;
|
|
111
|
-
|
|
126
|
+
const pendingNodes = orderNodes(opts.plan.nodes);
|
|
127
|
+
while (true) {
|
|
128
|
+
const decisionDb = new WorkflowDb(opts.projectDir);
|
|
129
|
+
const answered = decisionDb.answeredTicketDecisions(opts.runId, buildScopeRevision(opts.projectDir));
|
|
130
|
+
decisionDb.close();
|
|
131
|
+
for (let index = summaries.length - 1; index >= 0; index--) {
|
|
132
|
+
const summary = summaries[index];
|
|
133
|
+
if (summary.buildStatus === "done")
|
|
134
|
+
continue;
|
|
135
|
+
const candidate = opts.plan.nodes.find(item => item.ticket.id === summary.ticket);
|
|
136
|
+
if (!candidate || pendingNodes.some(item => item.ticket.id === candidate.ticket.id))
|
|
137
|
+
continue;
|
|
138
|
+
const hasAnswer = answered.some(decision => decision.interruptionId === `ticket:${candidate.ticket.id}`);
|
|
139
|
+
const dependencyDeferred = summary.detail === "deferred because a dependency or shared delivery unit is blocked";
|
|
140
|
+
const dependenciesReady = candidate.dependencies.every(id => successfulBranches.has(id));
|
|
141
|
+
const sharedReady = !summaries.some(prior => prior.ticket !== candidate.ticket.id && prior.buildStatus !== "done" && prior.detail !== "deferred because a dependency or shared delivery unit is blocked" && opts.plan.nodes.some(item => item.ticket.id === prior.ticket && item.deliveryUnitId && item.deliveryUnitId === candidate.deliveryUnitId));
|
|
142
|
+
if ((hasAnswer || dependencyDeferred) && dependenciesReady && sharedReady) {
|
|
143
|
+
summaries.splice(index, 1);
|
|
144
|
+
pendingNodes.push(candidate);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const node = pendingNodes.shift();
|
|
148
|
+
if (!node)
|
|
149
|
+
break;
|
|
150
|
+
const continuations = answered.filter(decision => decision.interruptionId === `ticket:${node.ticket.id}`);
|
|
151
|
+
const continuationSuffix = continuations.length ? `:answers:${continuations.map(decision => decision.decisionId).join(",")}` : "";
|
|
152
|
+
const blocked = summaries.filter(summary => summary.buildStatus !== "done");
|
|
153
|
+
if (blocked.length && opts.continueIndependentTickets === false)
|
|
154
|
+
break;
|
|
155
|
+
if (blocked.some(summary => node.dependencies.includes(summary.ticket)
|
|
156
|
+
|| opts.plan.nodes.some(prior => prior.ticket.id === summary.ticket && prior.deliveryUnitId && prior.deliveryUnitId === node.deliveryUnitId))) {
|
|
157
|
+
summaries.push(summaryFor(node, "blocked", "deferred because a dependency or shared delivery unit is blocked"));
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
112
160
|
const protocolDb = new WorkflowDb(opts.projectDir);
|
|
113
161
|
const protocolHead = protocolDb.qaTicketHead(opts.runId, node.ticket.id);
|
|
114
162
|
const finalizationRecovery = protocolHead.state === "finalizing";
|
|
@@ -229,7 +277,10 @@ export async function runBranchPlan(opts) {
|
|
|
229
277
|
dependencies: node.dependencies,
|
|
230
278
|
});
|
|
231
279
|
workflowCheckpoint(opts.projectDir, opts.runId, "builder-before", node.ticket.id, { branch: node.branch, worktree: node.worktreePath });
|
|
232
|
-
const
|
|
280
|
+
const continuationDb = new WorkflowDb(opts.projectDir);
|
|
281
|
+
const continuationSession = continuations.length ? continuationDb.branchResumeSession(opts.runId, node.ticket.id) : undefined;
|
|
282
|
+
continuationDb.close();
|
|
283
|
+
const resumeSession = continuationSession ?? opts.resumeSessions?.get(node.ticket.id);
|
|
233
284
|
const worktreePath = resumeSession?.worktreePath
|
|
234
285
|
?? (sharedUnit ? findWorktreeForBranch(opts.projectDir, node.branch) : undefined)
|
|
235
286
|
?? await observeNode(opts, node, "git", "creating ticket worktree", () => createTicketWorktree(opts.projectDir, opts.runId, node.branch, node.baseBranch));
|
|
@@ -248,7 +299,7 @@ export async function runBranchPlan(opts) {
|
|
|
248
299
|
});
|
|
249
300
|
}
|
|
250
301
|
if (!qaOnlyRecovery)
|
|
251
|
-
journalTracker(opts.projectDir, opts.runId, `${opts.runId}:tracker-update:${node.ticket.id}:in-progress`, { ticket: node.ticket.id, status: "in_progress" }, () => {
|
|
302
|
+
journalTracker(opts.projectDir, opts.runId, `${opts.runId}:tracker-update:${node.ticket.id}:in-progress${continuationSuffix}`, { ticket: node.ticket.id, status: "in_progress" }, () => {
|
|
252
303
|
if (resumeSession)
|
|
253
304
|
cmdUnblock(opts.projectDir, node.ticket.id, { actor: "foreman", summary: `Reopened by explicit branch recovery for ${node.branch}` });
|
|
254
305
|
cmdUpdate(opts.projectDir, node.ticket.id, {
|
|
@@ -256,7 +307,9 @@ export async function runBranchPlan(opts) {
|
|
|
256
307
|
summary: resumeSession ? `Resuming branch ${node.branch}` : `Starting branch ${node.branch}`,
|
|
257
308
|
});
|
|
258
309
|
});
|
|
259
|
-
|
|
310
|
+
let ticketInstruction = resumeSession ? buildBranchTicketResumeInstruction(node, opts.trackerPaths) : buildBranchTicketInstruction(node, opts.trackerPaths);
|
|
311
|
+
if (continuations.length)
|
|
312
|
+
ticketInstruction += "\n\nScoped answers authorizing this ticket continuation:\n" + continuations.map(decision => `${decision.prompt}\nAnswer: ${decision.answer ?? decision.selectedChoiceId}`).join("\n");
|
|
260
313
|
// Reattach the predecessor even for a `fresh` strategy so the host can
|
|
261
314
|
// publish and validate a cumulative handoff before creating its successor.
|
|
262
315
|
const sameWorktreeStream = builderStream && canonical(builderStream.worktreePath) === canonical(worktreePath) ? builderStream : undefined;
|
|
@@ -283,8 +336,26 @@ export async function runBranchPlan(opts) {
|
|
|
283
336
|
if (!qaOnlyRecovery)
|
|
284
337
|
builderWorkSessions += 1;
|
|
285
338
|
viewer = opts.observeBuilder?.(builder);
|
|
286
|
-
const foreman = new Foreman(builder, opts.log, { desktop: opts.notificationsEnabled, terminalBell: opts.terminalBellEnabled ?? true }, opts.qaEnabled, 3,
|
|
287
|
-
const turn = () =>
|
|
339
|
+
const foreman = new Foreman(builder, opts.log, { desktop: opts.notificationsEnabled, terminalBell: opts.terminalBellEnabled ?? true }, opts.qaEnabled, 3, opts.projectDir, undefined, undefined, opts.qaSessionStrategy ?? "compact", undefined, opts.builderSessionStrategy ?? "compact", undefined, opts.beforeBuilderTurn ? (adapter, action) => opts.beforeBuilderTurn(adapter, action, worktreePath) : undefined, undefined, undefined, undefined, async (adapter) => opts.observeBuilderNativeCompactions?.(adapter, worktreePath), opts.observer, undefined, false, undefined, undefined, opts.runId, opts.continueIndependentTickets ?? true, node.ticket.id);
|
|
340
|
+
const turn = async () => {
|
|
341
|
+
const db = new WorkflowDb(opts.projectDir);
|
|
342
|
+
try {
|
|
343
|
+
db.atomic(() => {
|
|
344
|
+
for (const decision of continuations) {
|
|
345
|
+
const key = `decision-continuation:${decision.decisionId}`;
|
|
346
|
+
db.planOperation({ runId: opts.runId, idempotencyKey: key, kind: "decision-continuation", intent: { decisionId: decision.decisionId, ticketId: node.ticket.id } });
|
|
347
|
+
db.updateOperation(key, "in_progress");
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
const response = await foreman.runInstruction(ticketInstruction);
|
|
351
|
+
for (const decision of continuations)
|
|
352
|
+
db.updateOperation(`decision-continuation:${decision.decisionId}`, response.result.failure?.dispatchState === "unknown" ? "uncertain" : "confirmed", { result: { isError: response.result.isError } });
|
|
353
|
+
return response;
|
|
354
|
+
}
|
|
355
|
+
finally {
|
|
356
|
+
db.close();
|
|
357
|
+
}
|
|
358
|
+
};
|
|
288
359
|
const { result, status } = qaOnlyRecovery
|
|
289
360
|
? { result: { text: "Exact QA-only recovery; Builder work dispatch was intentionally skipped.", isError: false, numTurns: 0, costUsd: 0 }, status: { kind: "done", summary: "Resuming exact QA boundary", ticket: node.ticket.id } }
|
|
290
361
|
: opts.observer
|
|
@@ -334,7 +405,7 @@ export async function runBranchPlan(opts) {
|
|
|
334
405
|
if (node.deliveryUnitId) {
|
|
335
406
|
const path = deliverySessionPath(opts.projectDir, node.deliveryUnitId);
|
|
336
407
|
mkdirSync(join(opts.projectDir, ".foreman", "delivery-sessions"), { recursive: true });
|
|
337
|
-
writeFileSync(path, `${JSON.stringify({ unitId: node.deliveryUnitId, branch: node.branch, worktreePath, sessionId, sessionRef: builder.sessionRef?.(), ticket: node.ticket.id }, null, 2)}\n`, "utf8");
|
|
408
|
+
writeFileSync(path, `${JSON.stringify({ runId: opts.runId, unitId: node.deliveryUnitId, branch: node.branch, worktreePath, sessionId, sessionRef: builder.sessionRef?.(), ticket: node.ticket.id }, null, 2)}\n`, "utf8");
|
|
338
409
|
}
|
|
339
410
|
}
|
|
340
411
|
opts.log.write("step", {
|
|
@@ -353,7 +424,7 @@ export async function runBranchPlan(opts) {
|
|
|
353
424
|
}
|
|
354
425
|
if (status.kind !== "done" && status.kind !== "plan_complete") {
|
|
355
426
|
const detail = status.reason ?? status.error ?? `builder emitted ${status.kind}`;
|
|
356
|
-
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" }));
|
|
427
|
+
journalTracker(opts.projectDir, opts.runId, `${opts.runId}:tracker-update:${node.ticket.id}:builder-block${continuationSuffix}`, { ticket: node.ticket.id, status: "blocked", detail }, () => cmdBlock(opts.projectDir, node.ticket.id, { summary: detail, actor: "foreman" }));
|
|
357
428
|
summaries.push(summaryFor(node, status.kind === "blocked" ? "blocked" : "needs-human", detail));
|
|
358
429
|
continue;
|
|
359
430
|
}
|
|
@@ -408,10 +479,18 @@ export async function runBranchPlan(opts) {
|
|
|
408
479
|
const sessionRef = typeof session === "string" ? undefined : session;
|
|
409
480
|
const sessionId = typeof session === "string" ? session : session.sessionId;
|
|
410
481
|
builderStream = { sessionId, ...(sessionRef ? { sessionRef } : {}), worktreePath };
|
|
482
|
+
const resumeDb = new WorkflowDb(opts.projectDir);
|
|
483
|
+
try {
|
|
484
|
+
resumeDb.recordBranchResumeSession(opts.runId, { ticket: node.ticket.id, branch: node.branch, base: node.baseBranch, worktreePath, sessionId, sessionRef, logPath: "structured-recovery", deliveryUnitId: node.deliveryUnitId });
|
|
485
|
+
}
|
|
486
|
+
finally {
|
|
487
|
+
resumeDb.close();
|
|
488
|
+
}
|
|
411
489
|
opts.recordBuilderSession?.(sessionRef ?? sessionId, node.ticket.id, worktreePath);
|
|
412
490
|
workflowCheckpoint(opts.projectDir, opts.runId, "builder-session-scoped", node.ticket.id, { sessionId, sessionRef, worktree: worktreePath, branch: node.branch });
|
|
413
491
|
},
|
|
414
492
|
});
|
|
493
|
+
opts.log.write("qa-fix", { ticket: node.ticket.id, outcome: result.outcome, operationId: result.operationId, turnRecordId: result.turnRecordId, detail: result.detail });
|
|
415
494
|
return result;
|
|
416
495
|
},
|
|
417
496
|
onNonconvergence: opts.qaNonconvergence,
|
|
@@ -431,7 +510,7 @@ export async function runBranchPlan(opts) {
|
|
|
431
510
|
workflowCheckpoint(opts.projectDir, opts.runId, "qa-after", node.ticket.id, { outcome: qa.outcome, detail: qa.detail });
|
|
432
511
|
if (qa.outcome !== "passed" && qa.outcome !== "waived") {
|
|
433
512
|
const detail = qa.detail ?? "QA did not pass";
|
|
434
|
-
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" }));
|
|
513
|
+
journalTracker(opts.projectDir, opts.runId, `${opts.runId}:tracker-update:${node.ticket.id}:qa-block${continuationSuffix}`, { ticket: node.ticket.id, status: "blocked", detail }, () => cmdBlock(opts.projectDir, node.ticket.id, { summary: detail, actor: "foreman" }));
|
|
435
514
|
summaries.push(summaryFor(node, qa.outcome === "blocked" ? "blocked" : "needs-human", detail));
|
|
436
515
|
continue;
|
|
437
516
|
}
|
|
@@ -696,7 +775,7 @@ export async function runBranchPlan(opts) {
|
|
|
696
775
|
: "builder_error";
|
|
697
776
|
opts.log.write("branch-issue", { ticket: node.ticket.id, code, message, blocking: false });
|
|
698
777
|
notifyIssue(opts.notificationsEnabled, `${node.ticket.id}: ${message}`);
|
|
699
|
-
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" }));
|
|
778
|
+
journalTracker(opts.projectDir, opts.runId, `${opts.runId}:tracker-update:${node.ticket.id}:runtime-block${continuationSuffix}`, { ticket: node.ticket.id, status: "blocked", detail: message }, () => cmdBlock(opts.projectDir, node.ticket.id, { summary: message, actor: "foreman" }));
|
|
700
779
|
summaries.push(summaryFor(node, "blocked", message));
|
|
701
780
|
}
|
|
702
781
|
finally {
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Reuse only source-bound approval. Ambiguous legacy provenance still asks. */
|
|
2
|
+
export declare function approvedBuildScope(projectDir: string, ticketIds?: readonly string[]): {
|
|
3
|
+
approved: boolean;
|
|
4
|
+
reason: string;
|
|
5
|
+
digest?: string;
|
|
6
|
+
};
|
|
7
|
+
export declare function requiresBuildApproval(projectDir: string, ticketIds: readonly string[] | undefined, explicitYes: boolean, consequences?: Record<string, unknown>): boolean;
|
|
8
|
+
/** Bind decisions to current files so an old answer cannot approve a changed scope. */
|
|
9
|
+
export declare function buildScopeRevision(projectDir: string, consequences?: Record<string, unknown>): string;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { loadDeliveryConfig, normalizeDeliveryConfig } from "./tickets/delivery.js";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { parse } from "yaml";
|
|
6
|
+
import { WorkflowDb } from "./workflowDb.js";
|
|
7
|
+
import { loadTicketsConfig, resolveTicketPaths } from "./tickets/config.js";
|
|
8
|
+
import { loadTickets } from "./tickets/ticketLoader.js";
|
|
9
|
+
import { StateDb } from "./tickets/stateDb.js";
|
|
10
|
+
function canonical(value) {
|
|
11
|
+
const normalize = (entry) => Array.isArray(entry) ? entry.map(normalize) : entry && typeof entry === "object"
|
|
12
|
+
? Object.fromEntries(Object.entries(entry).sort(([a], [b]) => a.localeCompare(b)).map(([key, val]) => [key, normalize(val)])) : entry;
|
|
13
|
+
return JSON.stringify(normalize(value));
|
|
14
|
+
}
|
|
15
|
+
function material(ticket) {
|
|
16
|
+
return { id: ticket.id, plan_ref: ticket.plan_ref, title: ticket.title, summary: ticket.summary, acceptance: ticket.acceptance,
|
|
17
|
+
required_tests: ticket.required_tests, depends_on: ticket.depends_on, likely_files: ticket.likely_files, rollback: ticket.rollback, source_refs: ticket.source_refs };
|
|
18
|
+
}
|
|
19
|
+
/** Reuse only source-bound approval. Ambiguous legacy provenance still asks. */
|
|
20
|
+
export function approvedBuildScope(projectDir, ticketIds) {
|
|
21
|
+
try {
|
|
22
|
+
const paths = resolveTicketPaths(loadTicketsConfig(projectDir), projectDir);
|
|
23
|
+
const all = loadTickets(paths.tickets);
|
|
24
|
+
const state = new StateDb(paths.stateDb);
|
|
25
|
+
let tickets;
|
|
26
|
+
try {
|
|
27
|
+
tickets = ticketIds ? all.filter(ticket => ticketIds.includes(ticket.id)) : all.filter(ticket => !["done", "obsolete", "cancelled", "canceled"].includes(state.getState(ticket.id)?.status ?? "planned"));
|
|
28
|
+
}
|
|
29
|
+
finally {
|
|
30
|
+
state.close();
|
|
31
|
+
}
|
|
32
|
+
if (!tickets.length || (ticketIds && tickets.length !== new Set(ticketIds).size) || tickets.some(ticket => !ticket.plan_ref))
|
|
33
|
+
return { approved: false, reason: "selected tickets lack approved plan provenance" };
|
|
34
|
+
const db = new WorkflowDb(projectDir);
|
|
35
|
+
try {
|
|
36
|
+
const approvals = db.completedPlanApprovals();
|
|
37
|
+
const definitions = db.approvedPopulationDefinitions();
|
|
38
|
+
for (const ticket of tickets) {
|
|
39
|
+
const ref = ticket.plan_ref;
|
|
40
|
+
const receipt = approvals.find(item => item.planId === ref.plan_id && item.revision === ref.revision);
|
|
41
|
+
if (!receipt)
|
|
42
|
+
return { approved: false, reason: `no stored approval for ${ticket.id}` };
|
|
43
|
+
const config = parse(readFileSync(join(projectDir, "rafi-config.yaml"), "utf8"));
|
|
44
|
+
const plan = JSON.parse(readFileSync(join(projectDir, config.docs?.root ?? "docs", "rafi-plan.json"), "utf8"));
|
|
45
|
+
const digest = createHash("sha256").update(canonical({ ...plan, content_digest: "" })).digest("hex");
|
|
46
|
+
if (plan.plan_id !== ref.plan_id || plan.revision !== ref.revision || digest !== plan.content_digest || digest !== receipt.digest)
|
|
47
|
+
return { approved: false, reason: "approved plan revision or digest changed" };
|
|
48
|
+
const approved = definitions.find(item => item.id === ticket.id && canonical(item.plan_ref) === canonical(ref));
|
|
49
|
+
// Older populations have no immutable definition receipt. Only an exact
|
|
50
|
+
// match to the digested approved slice is safe to reuse automatically.
|
|
51
|
+
const slice = plan.slices?.find((item) => item.slice_ref === ref.slice_ref);
|
|
52
|
+
const ids = new Map(all.filter(item => item.plan_ref?.plan_id === ref.plan_id && item.plan_ref.revision === ref.revision).map(item => [item.plan_ref.slice_ref, item.id]));
|
|
53
|
+
const legacy = slice && !ticket.rollback ? { ...ticket, title: slice.title, summary: slice.summary,
|
|
54
|
+
acceptance: slice.acceptance, required_tests: slice.required_tests, likely_files: slice.likely_files,
|
|
55
|
+
depends_on: (slice.depends_on ?? []).map((id) => ids.get(id)),
|
|
56
|
+
source_refs: slice.source_refs?.map((source) => ({ source: source.source_id, item: source.item ?? "document", source_id: source.source_id, fingerprint: source.fingerprint, ...(source.url !== undefined ? { url: source.url } : {}), ...(source.note !== undefined ? { note: source.note } : {}) })) } : undefined;
|
|
57
|
+
if (canonical(material(approved ?? legacy ?? {})) !== canonical(material(ticket)))
|
|
58
|
+
return { approved: false, reason: `material ticket changes or missing immutable approval for ${ticket.id}` };
|
|
59
|
+
const actualDelivery = loadDeliveryConfig(projectDir);
|
|
60
|
+
if (actualDelivery?.units.length && !Array.isArray(plan.delivery_units))
|
|
61
|
+
return { approved: false, reason: "delivery configuration has no approved provenance" };
|
|
62
|
+
if (Array.isArray(plan.delivery_units) && (plan.delivery_units.length || actualDelivery?.units.length)) {
|
|
63
|
+
const ids = new Map(all.filter(item => item.plan_ref?.plan_id === ref.plan_id).map(item => [item.plan_ref.slice_ref, item.id]));
|
|
64
|
+
const expected = normalizeDeliveryConfig({ version: 1, plan: { plan_id: plan.plan_id, revision: plan.revision },
|
|
65
|
+
units: plan.delivery_units.map((unit) => ({ ...unit, tickets: unit.slice_refs.map(slice => ids.get(slice)) })),
|
|
66
|
+
stacks: (plan.stacks ?? []).map((stack) => ({ id: stack.stack_id, name: stack.name, units: stack.units, status: "planned" })) });
|
|
67
|
+
const actual = loadDeliveryConfig(projectDir);
|
|
68
|
+
if (!actual || canonical(actual.units) !== canonical(expected.units))
|
|
69
|
+
return { approved: false, reason: "approved delivery consequences changed" };
|
|
70
|
+
}
|
|
71
|
+
const decisions = receipt.decisionReceipt;
|
|
72
|
+
if (!decisions || decisions.planDigest !== digest || decisions.workMode !== config.tickets?.build?.branch_strategy || decisions.branchPrefix !== config.tickets?.build?.branch_prefix)
|
|
73
|
+
return { approved: false, reason: "approved workflow consequences changed" };
|
|
74
|
+
}
|
|
75
|
+
return { approved: true, reason: "unchanged approved plan and ticket definitions", digest: createHash("sha256").update(canonical(tickets.map(material))).digest("hex") };
|
|
76
|
+
}
|
|
77
|
+
finally {
|
|
78
|
+
db.close();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return { approved: false, reason: "approval evidence unavailable" };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
export function requiresBuildApproval(projectDir, ticketIds, explicitYes, consequences) {
|
|
86
|
+
const scope = approvedBuildScope(projectDir, ticketIds);
|
|
87
|
+
let changedConsequences = false;
|
|
88
|
+
if (consequences) {
|
|
89
|
+
try {
|
|
90
|
+
const saved = parse(readFileSync(join(projectDir, "rafi-config.yaml"), "utf8"))?.tickets?.build ?? {};
|
|
91
|
+
changedConsequences = Object.entries(consequences).some(([key, value]) => value !== undefined && canonical(value) !== canonical(saved[key] ?? { completion: "none", branch_strategy: "current", branch_prefix: "feature", merge_method: "squash", pr_ready: false }[key]));
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
changedConsequences = true;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (scope.approved && !changedConsequences)
|
|
98
|
+
return false;
|
|
99
|
+
if (!explicitYes)
|
|
100
|
+
return true;
|
|
101
|
+
const db = new WorkflowDb(projectDir);
|
|
102
|
+
try {
|
|
103
|
+
return db.completedPlanApprovals().length > 0;
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
db.close();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/** Bind decisions to current files so an old answer cannot approve a changed scope. */
|
|
110
|
+
export function buildScopeRevision(projectDir, consequences) {
|
|
111
|
+
const paths = resolveTicketPaths(loadTicketsConfig(projectDir), projectDir);
|
|
112
|
+
let docsRoot = "docs";
|
|
113
|
+
try {
|
|
114
|
+
docsRoot = parse(readFileSync(join(projectDir, "rafi-config.yaml"), "utf8"))?.docs?.root ?? docsRoot;
|
|
115
|
+
}
|
|
116
|
+
catch { /* unavailable config changes the fingerprint below */ }
|
|
117
|
+
const contents = [join(projectDir, docsRoot, "rafi-plan.json"), paths.tickets, join(projectDir, "rafi-config.yaml"), join(projectDir, ".tickets/delivery.yaml")].map(path => {
|
|
118
|
+
try {
|
|
119
|
+
return readFileSync(path, "utf8");
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return "unavailable";
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
return createHash("sha256").update(JSON.stringify([contents, consequences])).digest("hex");
|
|
126
|
+
}
|
package/dist/buildRuns.js
CHANGED
|
@@ -52,7 +52,7 @@ export function createBuildRun(input) {
|
|
|
52
52
|
phase: "created",
|
|
53
53
|
qaEnabled: input.qaEnabled ?? Boolean(input.qa),
|
|
54
54
|
recoveryAttempts: [],
|
|
55
|
-
supervisor: { status: frozenPolicy.supervisorEnabled ? "
|
|
55
|
+
supervisor: { status: frozenPolicy.supervisorEnabled ? "stopped" : "disabled", generation: 0, workerGeneration: 0, checkpointRestarts: 0, runRestarts: 0 },
|
|
56
56
|
pendingDecisions: [],
|
|
57
57
|
deferredTickets: [],
|
|
58
58
|
runDecisions: input.runDecisions,
|
|
@@ -75,8 +75,8 @@ export function resumeBuildRun(projectDir, runId, patch, now = new Date()) {
|
|
|
75
75
|
let existing = readBuildRuns(projectDir).find((run) => run.runId === runId);
|
|
76
76
|
if (!existing)
|
|
77
77
|
throw new Error(`recoverable build run not found: ${runId}`);
|
|
78
|
-
if (existing.status === "completed")
|
|
79
|
-
throw new Error(`build run ${runId} is
|
|
78
|
+
if (existing.status === "completed" || existing.status === "superseded")
|
|
79
|
+
throw new Error(`build run ${runId} is ${existing.status} and cannot resume`);
|
|
80
80
|
const workflow = new WorkflowDb(projectDir);
|
|
81
81
|
try {
|
|
82
82
|
const held = workflow.currentLease();
|
|
@@ -96,7 +96,7 @@ export function resumeBuildRun(projectDir, runId, patch, now = new Date()) {
|
|
|
96
96
|
}
|
|
97
97
|
}
|
|
98
98
|
existing = readBuildRuns(projectDir).find((run) => run.runId === runId);
|
|
99
|
-
if (!existing || existing.status === "completed") {
|
|
99
|
+
if (!existing || existing.status === "completed" || existing.status === "superseded") {
|
|
100
100
|
const lease = workflow.currentLease();
|
|
101
101
|
if (lease?.runId === runId && lease.pid === process.pid)
|
|
102
102
|
workflow.releaseLease(lease, now);
|
|
@@ -140,33 +140,50 @@ export function saveBuildRun(projectDir, run, now = new Date()) {
|
|
|
140
140
|
updatedAt: now.toISOString(),
|
|
141
141
|
};
|
|
142
142
|
const workflow = new WorkflowDb(projectDir);
|
|
143
|
+
const target = join(directory, `${run.runId}.json`);
|
|
144
|
+
let publicationId;
|
|
143
145
|
try {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
146
|
+
workflow.atomic(() => {
|
|
147
|
+
const frozenPolicy = workflow.freezeAutonomyPolicy(next.runId, next.frozenPolicy, now);
|
|
148
|
+
next = { ...next, frozenPolicy, recoveryAttempts: workflow.recoveryAttempts(next.runId), pendingDecisions: workflow.pendingHumanDecisions(next.runId), supervisor: workflow.supervisorState(next.runId) ?? next.supervisor };
|
|
149
|
+
const existing = workflow.getRun(next.runId);
|
|
150
|
+
if (existing?.status === "superseded" && next.status !== "superseded")
|
|
151
|
+
throw new Error("cannot rewrite a superseded build run as active");
|
|
152
|
+
if (!existing)
|
|
153
|
+
workflow.createRun({
|
|
154
|
+
runId: next.runId, kind: "build", checkpoint: next.checkpoint,
|
|
155
|
+
originalWork: { tickets: next.tickets, deliveryUnit: next.deliveryUnit, branchMode: next.branchMode },
|
|
156
|
+
remainingWork: { tickets: remainingTickets(next) }, state: next,
|
|
157
|
+
}, now);
|
|
158
|
+
else
|
|
159
|
+
workflow.transition(next.runId, {
|
|
160
|
+
status: workflowStatus(next.status), checkpoint: next.checkpoint,
|
|
161
|
+
remainingWork: { tickets: remainingTickets(next) }, state: next, event: "build_snapshot",
|
|
162
|
+
}, now);
|
|
163
|
+
if (next.builder?.sessionId)
|
|
164
|
+
workflow.recordSession(next.runId, "builder", "builder", latestSessionBinding(next.sessionBindings, "builder", next.builder.sessionId) ?? next.builder.sessionId, "checkpoint", next.builder.settings, now);
|
|
165
|
+
if (next.qa?.sessionId)
|
|
166
|
+
workflow.recordSession(next.runId, "qa", "qa", latestSessionBinding(next.sessionBindings, "qa", next.qa.sessionId) ?? next.qa.sessionId, "checkpoint", next.qa.settings, now);
|
|
167
|
+
publicationId = workflow.beginPublication(next.runId, { operation: "build-run-projection", target,
|
|
168
|
+
revision: createHash("sha256").update(JSON.stringify(next)).digest("hex"), updatedAt: next.updatedAt }, {}, now).transactionId;
|
|
169
|
+
});
|
|
170
|
+
const temp = `${target}.${process.pid}.${randomUUID()}.tmp`;
|
|
171
|
+
writeFileSync(temp, `${JSON.stringify(next, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
172
|
+
renameSync(temp, target);
|
|
173
|
+
workflow.atomic(() => {
|
|
174
|
+
workflow.updatePublication(publicationId, "committed", now);
|
|
175
|
+
// Publishing the latest authoritative revision also reconciles any older
|
|
176
|
+
// interrupted publication of this same projection; never write old bytes.
|
|
177
|
+
for (const pending of workflow.incompletePublications()) {
|
|
178
|
+
const intent = pending.intent;
|
|
179
|
+
if (pending.runId === next.runId && intent.operation === "build-run-projection" && intent.target === target)
|
|
180
|
+
workflow.updatePublication(pending.transactionId, "committed", now);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
162
183
|
}
|
|
163
184
|
finally {
|
|
164
185
|
workflow.close();
|
|
165
186
|
}
|
|
166
|
-
const target = join(directory, `${run.runId}.json`);
|
|
167
|
-
const temp = `${target}.${process.pid}.${randomUUID()}.tmp`;
|
|
168
|
-
writeFileSync(temp, `${JSON.stringify(next, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
169
|
-
renameSync(temp, target);
|
|
170
187
|
return next;
|
|
171
188
|
}
|
|
172
189
|
export function checkpointBuildRun(projectDir, run, checkpoint, patch = {}) {
|
|
@@ -251,16 +268,37 @@ export function completeBuildRun(projectDir, run, now = new Date()) {
|
|
|
251
268
|
}
|
|
252
269
|
export function readBuildRuns(projectDir) {
|
|
253
270
|
const directory = join(resolve(projectDir), BUILD_RUN_DIRECTORY);
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
271
|
+
const workflow = new WorkflowReader(projectDir);
|
|
272
|
+
const candidates = new Map();
|
|
273
|
+
if (existsSync(directory))
|
|
274
|
+
for (const name of readdirSync(directory).filter(name => name.endsWith(".json"))) {
|
|
275
|
+
try {
|
|
276
|
+
const value = JSON.parse(readFileSync(join(directory, name), "utf8"));
|
|
277
|
+
candidates.set(value.runId, value);
|
|
278
|
+
}
|
|
279
|
+
catch { /* authoritative DB state can repair an absent or partial projection */ }
|
|
280
|
+
}
|
|
281
|
+
for (const stored of workflow.buildRuns()) {
|
|
282
|
+
const value = stored.state;
|
|
283
|
+
if (value?.runId)
|
|
284
|
+
candidates.set(value.runId, value);
|
|
285
|
+
}
|
|
286
|
+
const runs = [...candidates.values()].flatMap((projection) => {
|
|
258
287
|
try {
|
|
259
|
-
const projection = JSON.parse(readFileSync(join(directory, name), "utf8"));
|
|
260
288
|
validateBuildRun(projection);
|
|
261
289
|
const authoritative = workflow?.getRun(projection.runId)?.state;
|
|
262
290
|
const run = authoritative?.runId ? authoritative : projection;
|
|
263
|
-
|
|
291
|
+
let upgraded = upgradeBuildRun(run, projectDir);
|
|
292
|
+
if (workflow?.getRun(run.runId)?.status === "superseded")
|
|
293
|
+
upgraded = { ...upgraded, status: "superseded" };
|
|
294
|
+
if (!["completed", "superseded", "cancelled"].includes(workflow?.getRun(run.runId)?.status ?? "")) {
|
|
295
|
+
for (const role of ["builder", "qa"]) {
|
|
296
|
+
const lease = workflow?.roleMutationLease(run.runId, role);
|
|
297
|
+
const ref = lease?.sessionRef;
|
|
298
|
+
if (ref && lease.generation > 0 && upgraded[role])
|
|
299
|
+
upgraded = { ...upgraded, [role]: { ...upgraded[role], sessionId: ref.sessionId }, sessionBindings: upsertSessionBinding(upgraded.sessionBindings, ref) };
|
|
300
|
+
}
|
|
301
|
+
}
|
|
264
302
|
return [{ ...upgraded, frozenPolicy: workflow?.autonomyPolicy(run.runId) ?? upgraded.frozenPolicy, recoveryAttempts: workflow?.recoveryAttempts(run.runId) ?? upgraded.recoveryAttempts, pendingDecisions: workflow?.pendingHumanDecisions(run.runId) ?? upgraded.pendingDecisions, supervisor: workflow?.supervisorState(run.runId) ?? upgraded.supervisor }];
|
|
265
303
|
}
|
|
266
304
|
catch {
|
package/dist/cli/recovery.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isLiveProcessIdentity } from "../processIdentity.js";
|
|
1
2
|
import { Command } from "commander";
|
|
2
3
|
import { resolve } from "node:path";
|
|
3
4
|
import { readBuildRuns } from "../buildRuns.js";
|
|
@@ -35,10 +36,11 @@ export function buildDecideCommand() {
|
|
|
35
36
|
.requiredOption("--run <id>", "build run ID")
|
|
36
37
|
.requiredOption("--decision <id>", "decision ID")
|
|
37
38
|
.requiredOption("--choice <id>", "stable choice ID")
|
|
39
|
+
.option("--answer <text>", "actual answer to a scoped QA handback question")
|
|
38
40
|
.action((project, opts) => {
|
|
39
41
|
const db = new WorkflowDb(resolve(project));
|
|
40
42
|
try {
|
|
41
|
-
const decision = db.answerHumanDecision(opts.run, opts.decision, opts.choice);
|
|
43
|
+
const decision = db.answerHumanDecision(opts.run, opts.decision, opts.choice, new Date(), opts.answer);
|
|
42
44
|
console.log(`rafi: recorded ${decision.decisionId}=${opts.choice}`);
|
|
43
45
|
}
|
|
44
46
|
finally {
|
|
@@ -57,20 +59,14 @@ export function buildStopCommand() {
|
|
|
57
59
|
const state = db.supervisorState(opts.run);
|
|
58
60
|
if (!state)
|
|
59
61
|
throw new Error(`supervisor state not found for run ${opts.run}`);
|
|
62
|
+
if (!state.pid || !state.processStart || !isLiveProcessIdentity(state.pid, state.processStart))
|
|
63
|
+
throw new Error("supervisor process identity cannot be verified; refusing to signal a potentially reused PID");
|
|
60
64
|
const next = { ...state, status: "stopping", stopRequestedAt: new Date().toISOString() };
|
|
61
65
|
db.putSupervisorState(opts.run, next);
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
catch { /* stale worker */ }
|
|
67
|
-
}
|
|
68
|
-
if (state.pid && state.pid !== process.pid) {
|
|
69
|
-
try {
|
|
70
|
-
process.kill(state.pid, "SIGTERM");
|
|
71
|
-
}
|
|
72
|
-
catch { /* stale supervisor */ }
|
|
73
|
-
}
|
|
66
|
+
// The verified parent owns child cleanup; raw persisted worker PIDs may
|
|
67
|
+
// have been reused and must never be signalled independently.
|
|
68
|
+
if (state.pid !== process.pid)
|
|
69
|
+
process.kill(state.pid, "SIGTERM");
|
|
74
70
|
console.log(`rafi: stop requested for ${opts.run}`);
|
|
75
71
|
}
|
|
76
72
|
finally {
|
|
@@ -7,6 +7,13 @@ export interface RuntimeCommandRecoveryContext {
|
|
|
7
7
|
}
|
|
8
8
|
export interface RuntimeReadyForCommandOptions {
|
|
9
9
|
label: string;
|
|
10
|
+
timeoutMs?: number;
|
|
11
|
+
durable?: {
|
|
12
|
+
projectDir: string;
|
|
13
|
+
runId: string;
|
|
14
|
+
scopeRevision: string;
|
|
15
|
+
};
|
|
16
|
+
onTrace?: import("../runtimeReadiness.js").ProbeRuntimeOptions["onTrace"];
|
|
10
17
|
yes?: boolean;
|
|
11
18
|
allowSwitch?: boolean;
|
|
12
19
|
model?: string | undefined;
|
|
@@ -4,11 +4,14 @@ import { checkRuntimeReady, RuntimeAuthError, runtimeCommandLabel, } from "../ru
|
|
|
4
4
|
import { resolveExecutablePath } from "../runtimeReadiness.js";
|
|
5
5
|
import { otherRuntime, runtimeDisplayName } from "./runtimeSelection.js";
|
|
6
6
|
import { currentActivity } from "../activity.js";
|
|
7
|
+
import { durableHumanDecision } from "../humanDecision.js";
|
|
8
|
+
import { WorkflowDb } from "../workflowDb.js";
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
7
10
|
export async function ensureRuntimeReadyForCommand(projectDir, runtime, labelOrOptions) {
|
|
8
11
|
const opts = typeof labelOrOptions === "string"
|
|
9
12
|
? { label: labelOrOptions }
|
|
10
13
|
: labelOrOptions;
|
|
11
|
-
const check = opts.check ?? checkRuntimeReady;
|
|
14
|
+
const check = opts.check ?? ((projectDir, runtime) => checkRuntimeReady(projectDir, runtime, { onTrace: opts.onTrace, timeoutMs: opts.timeoutMs }));
|
|
12
15
|
const checkClaudeSdk = opts.checkClaudeSdk ?? requireClaudeSDK;
|
|
13
16
|
const allowSwitch = opts.allowSwitch !== false;
|
|
14
17
|
const nonInteractive = !opts.choose && (Boolean(opts.yes) || !process.stdin.isTTY || !process.stdout.isTTY);
|
|
@@ -31,13 +34,15 @@ export async function ensureRuntimeReadyForCommand(projectDir, runtime, labelOrO
|
|
|
31
34
|
stderr: err instanceof Error ? err.message : String(err),
|
|
32
35
|
cause: err,
|
|
33
36
|
});
|
|
34
|
-
if (nonInteractive) {
|
|
37
|
+
if (nonInteractive && !opts.durable) {
|
|
35
38
|
throw failure;
|
|
36
39
|
}
|
|
37
40
|
const fallbackRuntime = otherRuntime(runtime);
|
|
38
|
-
const choice = opts.choose
|
|
39
|
-
? await opts.
|
|
40
|
-
:
|
|
41
|
+
const choice = opts.durable && !opts.choose
|
|
42
|
+
? await durableRuntimeRecovery(opts.durable, failure, opts.label, fallbackRuntime, allowSwitch)
|
|
43
|
+
: opts.choose
|
|
44
|
+
? await opts.choose(failure, { otherRuntime: fallbackRuntime, allowSwitch })
|
|
45
|
+
: await promptRuntimeRecovery(failure, opts.label, fallbackRuntime, allowSwitch);
|
|
41
46
|
if (choice === "retry") {
|
|
42
47
|
currentActivity()?.note(`rafi: retrying ${runtime} readiness check`);
|
|
43
48
|
continue;
|
|
@@ -72,6 +77,8 @@ export async function ensureRuntimeReadyForCommand(projectDir, runtime, labelOrO
|
|
|
72
77
|
}
|
|
73
78
|
}
|
|
74
79
|
if (choice === "cancel") {
|
|
80
|
+
if (opts.durable)
|
|
81
|
+
throw failure;
|
|
75
82
|
console.log("foreman: cancelled");
|
|
76
83
|
process.exit(0);
|
|
77
84
|
}
|
|
@@ -79,6 +86,34 @@ export async function ensureRuntimeReadyForCommand(projectDir, runtime, labelOrO
|
|
|
79
86
|
}
|
|
80
87
|
}
|
|
81
88
|
}
|
|
89
|
+
async function durableRuntimeRecovery(scope, failure, label, fallbackRuntime, allowSwitch) {
|
|
90
|
+
const db = new WorkflowDb(scope.projectDir);
|
|
91
|
+
try {
|
|
92
|
+
db.ensureRun(scope.runId);
|
|
93
|
+
const identity = createHash("sha256").update(JSON.stringify([scope.scopeRevision, failure.runtime, label, allowSwitch])).digest("hex");
|
|
94
|
+
const prefix = `runtime-recovery:${scope.runId}:${identity}:`;
|
|
95
|
+
const attempt = db.operations(scope.runId).filter(item => item.kind === "runtime-recovery-decision" && item.idempotencyKey.startsWith(prefix)).length;
|
|
96
|
+
const key = `${prefix}${attempt}`;
|
|
97
|
+
console.error(failure.message);
|
|
98
|
+
const choice = await durableHumanDecision({
|
|
99
|
+
projectDir: scope.projectDir, runId: scope.runId, key,
|
|
100
|
+
prompt: `${runtimeCommandLabel(failure.runtime)} is not ready for ${label}. Choose how to recover.`,
|
|
101
|
+
choices: [{ id: "retry", label: "Fix manually and retry" }, ...(allowSwitch ? [{ id: "switch", label: `Use ${runtimeDisplayName(fallbackRuntime)}` }] : []), { id: "cancel", label: "Stop and preserve work" }],
|
|
102
|
+
operation: () => promptRuntimeRecovery(failure, label, fallbackRuntime, allowSwitch),
|
|
103
|
+
});
|
|
104
|
+
// Spend this authorization before executing it. A later failure must never
|
|
105
|
+
// reuse an old retry answer and turn a human decision into an infinite loop.
|
|
106
|
+
db.atomic(() => {
|
|
107
|
+
db.planOperation({ runId: scope.runId, idempotencyKey: key, kind: "runtime-recovery-decision", intent: { choice } });
|
|
108
|
+
db.updateOperation(key, "in_progress");
|
|
109
|
+
db.updateOperation(key, "confirmed", { result: { choice } });
|
|
110
|
+
});
|
|
111
|
+
return choice;
|
|
112
|
+
}
|
|
113
|
+
finally {
|
|
114
|
+
db.close();
|
|
115
|
+
}
|
|
116
|
+
}
|
|
82
117
|
async function promptRuntimeRecovery(err, label, fallbackRuntime, allowSwitch) {
|
|
83
118
|
log.error(err.message);
|
|
84
119
|
log.info("Cancel stops this command and keeps project files in place. It does not uninstall packages, delete generated files, or change configuration.");
|