ai-foreman 1.7.25 → 1.7.26
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 +2 -1
- package/dist/adapters/claude.d.ts +15 -0
- package/dist/adapters/claude.js +149 -28
- package/dist/adapters/codex.d.ts +9 -0
- package/dist/adapters/codex.js +77 -8
- package/dist/adapters/fastMode.d.ts +4 -0
- package/dist/adapters/fastMode.js +9 -0
- package/dist/adapters/types.d.ts +2 -0
- package/dist/agentRun.js +1 -0
- package/dist/branch/runner.js +46 -44
- package/dist/buildAssignment.d.ts +15 -0
- package/dist/buildAssignment.js +142 -5
- package/dist/buildRuns.d.ts +6 -0
- package/dist/buildRuns.js +73 -7
- package/dist/cli/start.d.ts +5 -0
- package/dist/cli/start.js +123 -33
- package/dist/continuity.js +11 -10
- package/dist/foreman.d.ts +8 -0
- package/dist/foreman.js +69 -10
- package/dist/handoffProtocol.d.ts +4 -0
- package/dist/handoffProtocol.js +19 -0
- package/dist/handoffs.d.ts +1 -1
- package/dist/handoffs.js +23 -4
- package/dist/permissions/policy.js +18 -11
- package/dist/permissions/shellForms.d.ts +3 -0
- package/dist/permissions/shellForms.js +203 -0
- package/dist/qaBuilderActivation.d.ts +19 -0
- package/dist/qaBuilderActivation.js +112 -0
- package/dist/qaFailureDelivery.js +1 -1
- package/dist/qaRecovery.js +2 -1
- package/dist/qaReview.d.ts +4 -0
- package/dist/qaReview.js +37 -7
- package/dist/sessionLifecycle.js +1 -1
- package/dist/supervisedStart.js +23 -3
- package/dist/supervisor.js +33 -0
- package/dist/tickets/groupReset.js +100 -96
- package/dist/tickets/reset.js +80 -69
- package/dist/workflowDb.d.ts +16 -1
- package/dist/workflowDb.js +276 -8
- package/package.json +1 -1
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
3
|
+
import { assertBuildAssignmentReconciled, captureBuildSource } from "./buildAssignment.js";
|
|
4
|
+
import { HandoffService, HandoffRecoveryPausedError } from "./handoffs.js";
|
|
5
|
+
import { qaDigest } from "./qaProtocolV2.js";
|
|
6
|
+
import { providerSessionKey } from "./sessionIdentity.js";
|
|
7
|
+
import { loadTicketsConfig, resolveTicketPaths } from "./tickets/config.js";
|
|
8
|
+
import { loadTickets } from "./tickets/ticketLoader.js";
|
|
9
|
+
import { WorkflowDb } from "./workflowDb.js";
|
|
10
|
+
/** QA-only startup must not replace Builder ownership. Activate a Builder only
|
|
11
|
+
* at an authorized work boundary, through the ordinary validated handoff. */
|
|
12
|
+
export async function activateQaRecoveryBuilder(input) {
|
|
13
|
+
const inspect = () => {
|
|
14
|
+
const db = new WorkflowDb(input.projectDir);
|
|
15
|
+
try {
|
|
16
|
+
const lease = db.currentLease();
|
|
17
|
+
if (lease?.runId !== input.runId || lease.pid !== process.pid)
|
|
18
|
+
throw new Error("Builder activation requires the current build execution lease");
|
|
19
|
+
const run = db.getRun(input.runId)?.state;
|
|
20
|
+
if (!run?.tickets.includes(input.ticketId) || run.progress.completedTickets.includes(input.ticketId))
|
|
21
|
+
throw new Error("QA recovery cannot activate Builder work outside unfinished approved scope");
|
|
22
|
+
const admission = db.assertAdmittedWork(input.runId, input.ticketId);
|
|
23
|
+
const definition = loadTickets(resolveTicketPaths(loadTicketsConfig(input.projectDir), input.projectDir).tickets).find(ticket => ticket.id === input.ticketId);
|
|
24
|
+
if (!definition || qaDigest("admitted-requirements", definition) !== admission.requirementsDigest)
|
|
25
|
+
throw new Error("Builder recovery requirements changed; renew scope approval before implementation");
|
|
26
|
+
if (!run.builder || qaDigest("builder-settings", run.builder.settings) !== qaDigest("builder-settings", input.settings))
|
|
27
|
+
throw new Error("QA recovery cannot replace the saved Builder settings");
|
|
28
|
+
if (input.expectedCapabilities && qaDigest("builder-capabilities", run.builderCapabilities) !== qaDigest("builder-capabilities", input.expectedCapabilities))
|
|
29
|
+
throw new Error("Builder capability approval changed before recovery activation");
|
|
30
|
+
if (db.unresolvedRoleDispatches(input.runId, "builder").length)
|
|
31
|
+
throw new Error("Reconcile unknown Builder dispatch before activating another session");
|
|
32
|
+
assertBuildAssignmentReconciled(input.projectDir, input.runId, input.worktree);
|
|
33
|
+
const owner = db.roleMutationLease(input.runId, "builder");
|
|
34
|
+
const ref = owner?.sessionRef;
|
|
35
|
+
if (!owner || !Number.isSafeInteger(owner.generation) || owner.generation < 0 || !ref || ref.version !== 1 || ref.role !== "builder" || ref.stream !== "builder"
|
|
36
|
+
|| !Number.isSafeInteger(ref.generation) || ref.generation < 0 || ref.sessionId !== owner.providerSessionId
|
|
37
|
+
|| ref.source !== "observed" || !ref.validatedAt || Number.isNaN(Date.parse(ref.validatedAt)) || providerSessionKey(ref) !== owner.sessionKey
|
|
38
|
+
|| realpathSync(ref.cwd) !== realpathSync(input.worktree) || realpathSync(ref.configRoot) !== realpathSync(input.projectDir))
|
|
39
|
+
throw new Error("QA recovery has no validated Builder owner for this worktree");
|
|
40
|
+
const branchSession = db.branchResumeSessions(false).find(session => session.ticket === input.ticketId && realpathSync(session.worktreePath) === realpathSync(input.worktree));
|
|
41
|
+
const branch = branchSession?.branch ?? (realpathSync(run.repository.worktree) === realpathSync(input.worktree) ? run.repository.branch : undefined);
|
|
42
|
+
if (!branch || execFileSync("git", ["branch", "--show-current"], { cwd: input.worktree, encoding: "utf8" }).trim() !== branch)
|
|
43
|
+
throw new Error("Return to the approved Builder worktree branch before recovery activation");
|
|
44
|
+
const head = db.continuityHead(input.runId, "builder");
|
|
45
|
+
if (!head || head.state !== "current" || !db.latestContinuityCheckpoint(input.runId, "builder"))
|
|
46
|
+
throw new Error("Builder continuity needs reconstruction before QA can request implementation");
|
|
47
|
+
return { owner, ref, head, source: captureBuildSource(input.worktree), requirementsDigest: admission.requirementsDigest,
|
|
48
|
+
capabilitiesDigest: qaDigest("builder-capabilities", run.builderCapabilities), compactionCount: db.successfulCompactionCount(input.runId, "builder", ref) };
|
|
49
|
+
}
|
|
50
|
+
finally {
|
|
51
|
+
db.close();
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
const before = inspect();
|
|
55
|
+
const currentRef = input.adapter.sessionRef?.();
|
|
56
|
+
if (currentRef && providerSessionKey(currentRef) === before.owner.sessionKey && currentRef.generation === before.ref.generation
|
|
57
|
+
&& currentRef.role === "builder" && currentRef.stream === "builder" && realpathSync(currentRef.configRoot) === realpathSync(input.projectDir)
|
|
58
|
+
&& currentRef.source === "observed" && currentRef.validatedAt)
|
|
59
|
+
return input.adapter;
|
|
60
|
+
const assertPreparedState = (current) => {
|
|
61
|
+
if (current.owner.generation !== before.owner.generation || current.owner.sessionKey !== before.owner.sessionKey
|
|
62
|
+
|| current.head.digest !== before.head.digest || current.source.digest !== before.source.digest || current.capabilitiesDigest !== before.capabilitiesDigest)
|
|
63
|
+
throw new Error("Builder recovery state changed while preparing its handoff; inspect and retry");
|
|
64
|
+
};
|
|
65
|
+
const transfer = await new HandoffService(input.projectDir).transfer({
|
|
66
|
+
runId: input.runId, role: "builder", reason: "Authorized Builder work after QA-only recovery requires a validated Builder owner",
|
|
67
|
+
predecessorSessionId: before.ref.sessionId, predecessorSessionRef: before.ref,
|
|
68
|
+
compactionCount: before.compactionCount, compactMaximum: input.settings.compact_maximum ?? 10,
|
|
69
|
+
roleState: { ticketId: input.ticketId, requirementsDigest: before.requirementsDigest, sourceDigest: before.source.digest,
|
|
70
|
+
capabilitiesDigest: before.capabilitiesDigest, recovery: "qa-builder-activation", providerReplayAuthorized: false },
|
|
71
|
+
resources: [{ label: "frozen-action", content: input.frozenAction, authoritative: true, purpose: "Approved QA remediation or guidance action; no replay of historical work" }],
|
|
72
|
+
}, async (handoff, runtime) => {
|
|
73
|
+
assertPreparedState(inspect());
|
|
74
|
+
const successor = await input.createSuccessor(handoff, runtime);
|
|
75
|
+
try {
|
|
76
|
+
assertPreparedState(inspect());
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
await successor.close().catch(() => { });
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
return successor;
|
|
83
|
+
}, input.recovery);
|
|
84
|
+
let owned;
|
|
85
|
+
try {
|
|
86
|
+
owned = await input.wrap(transfer.successor);
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
let drainError;
|
|
90
|
+
try {
|
|
91
|
+
await transfer.successor.close();
|
|
92
|
+
}
|
|
93
|
+
catch (failure) {
|
|
94
|
+
drainError = failure;
|
|
95
|
+
}
|
|
96
|
+
throw new HandoffRecoveryPausedError(input.runId, transfer.manifest.generation, `Builder handoff was accepted but could not install its execution wrapper: ${String(error)}.${drainError ? ` The accepted session also needs cleanup: ${String(drainError)}.` : " The accepted session was closed without implementation."} The recorded accepted owner is preserved; inspect this build with rafi resume before continuing.`);
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
await input.adapter.close();
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
let drainError;
|
|
103
|
+
try {
|
|
104
|
+
await owned.close();
|
|
105
|
+
}
|
|
106
|
+
catch (failure) {
|
|
107
|
+
drainError = failure;
|
|
108
|
+
}
|
|
109
|
+
throw new HandoffRecoveryPausedError(input.runId, transfer.manifest.generation, `Builder handoff was accepted, but the unused startup session could not close: ${String(error)}.${drainError ? ` The accepted session also needs cleanup: ${String(drainError)}.` : " The accepted session was closed without implementation."} Ownership remains with the recorded accepted session; reconcile cleanup with rafi resume before continuing.`);
|
|
110
|
+
}
|
|
111
|
+
return owned;
|
|
112
|
+
}
|
|
@@ -372,7 +372,7 @@ export class QaFailureDeliveryService {
|
|
|
372
372
|
Object.assign(record, { providerInstructionDigest: db.putEvidence("handoff", Buffer.from(turn.providerInstruction)), providerInstructionBytes: Buffer.byteLength(turn.providerInstruction), providerInstructionAvailability: "captured" });
|
|
373
373
|
}
|
|
374
374
|
record.eventEvidenceDigest = db.putEvidence("qa", Buffer.from(canonicalEvidence(events)));
|
|
375
|
-
record.toolCount = events.filter(e => e.kind === "tool" || e.kind === "provider-item" && !["agentMessage", "reasoning", "userMessage", "contextCompaction"].includes(e.itemType)).length;
|
|
375
|
+
record.toolCount = events.filter(e => e.kind === "tool" || e.kind === "provider-item" && !["text", "agentMessage", "reasoning", "userMessage", "contextCompaction"].includes(e.itemType)).length;
|
|
376
376
|
record.terminalCount = events.filter(e => e.kind === "turn-complete").length;
|
|
377
377
|
db.recordQaDeliveryTurn(record);
|
|
378
378
|
this.faults.afterResponseStored?.();
|
package/dist/qaRecovery.js
CHANGED
|
@@ -426,9 +426,10 @@ export function materializeQaRecoveryContext(packet, qaWorktree) {
|
|
|
426
426
|
} };
|
|
427
427
|
}
|
|
428
428
|
export function renderQaRecoveryAcknowledgementInstruction(packet, relativePath) {
|
|
429
|
-
const required = packet.manifest.resources.filter((resource) => resource.requiredForRecovery).map((resource) => `- path=${resource.path} purpose=${JSON.stringify(resource.purpose)} media_type=${resource.mediaType} bytes=${resource.bytes} digest=${resource.digest}`).join("\n");
|
|
429
|
+
const required = packet.manifest.resources.filter((resource) => resource.requiredForRecovery).map((resource) => `- read_path=${join(relativePath, resource.path)} packet_path=${resource.path} purpose=${JSON.stringify(resource.purpose)} media_type=${resource.mediaType} bytes=${resource.bytes} digest=${resource.digest}`).join("\n");
|
|
430
430
|
return [
|
|
431
431
|
"Read and verify every required recovery resource in this exact sealed packet revision before replying.",
|
|
432
|
+
"Use each exact read_path below within the current QA working directory. The normal read-only QA permissions apply; preserve the sealed packet and source.",
|
|
432
433
|
`Recovery context directory: ${relativePath}`,
|
|
433
434
|
`Packet ID: ${packet.manifest.packetId}`,
|
|
434
435
|
`Packet revision: ${packet.manifest.revision}`,
|
package/dist/qaReview.d.ts
CHANGED
|
@@ -208,6 +208,10 @@ export declare function runIsolatedQa(opts: IsolatedQaOptions): Promise<Isolated
|
|
|
208
208
|
export declare function buildQaReviewHandoff(ticket: TicketDef, builderSummary: string, diffDigest: string, validationChecklist: string[], changeSummary?: unknown, history?: QaReportHistoryEntry[]): string;
|
|
209
209
|
export declare function compactWithRetry(adapter: BuilderAdapter): Promise<CompactResult>;
|
|
210
210
|
export declare const QA_REPORT_RECOVERY_MENU: readonly ["Try a complete fresh QA review.", "Manually fix saved JSON for human inspection.", "Give QA specific instructions for a fresh review.", "Pause."];
|
|
211
|
+
/** Shared invariant for host staging and the actual QA boundary receipt. */
|
|
212
|
+
export declare function assertQaRecoveryHandoffInventory(resources: ReadonlyArray<{
|
|
213
|
+
label: string;
|
|
214
|
+
}>): void;
|
|
211
215
|
export declare function beginQaFinalization(projectDir: string, sourceWorktree: string, runId: string, ticketId: string, certificateId: string, expectedSourceStateDigest: string, consumer: string, allowedProjectionPaths?: string[]): Promise<void>;
|
|
212
216
|
export declare function verifyPendingQaFinalizationSource(projectDir: string, sourceWorktree: string, runId: string, ticketId: string, allowProjection?: boolean, projectionOperationId?: string): Promise<void>;
|
|
213
217
|
export declare function completeQaFinalization(projectDir: string, runId: string, ticketId: string): void;
|
package/dist/qaReview.js
CHANGED
|
@@ -200,8 +200,10 @@ async function runIsolatedQaOwned(opts) {
|
|
|
200
200
|
if (protocolHead.state === "source-frozen" || protocolHead.state === "review-ready") {
|
|
201
201
|
protocolHead = scopeDb.transitionQa(opts.recovery.runId, opts.ticket.id, protocolHead.revision, { type: "operator-menu" });
|
|
202
202
|
}
|
|
203
|
-
if (opts.resumedRecovery && protocolHead.state === "recheck-required"
|
|
203
|
+
if (opts.resumedRecovery && protocolHead.state === "recheck-required") {
|
|
204
204
|
opts.resumedRecovery = updateQaRecoveryPosition(opts.resumedRecovery, "qa-full-review", opts.resumedRecovery.manifest.correctionTurns, "qa-full-review");
|
|
205
|
+
if (scopeDb.hasConfirmedQaRemediationForRecheck(opts.recovery.runId, opts.ticket.id))
|
|
206
|
+
opts.resumedRecovery = undefined;
|
|
205
207
|
}
|
|
206
208
|
durableReviews = scopeDb.qaReviewAttempts(opts.recovery.runId, opts.ticket.id);
|
|
207
209
|
for (const interrupted of durableReviews.filter((item) => item.status === "started"))
|
|
@@ -324,6 +326,8 @@ async function runIsolatedQaOwned(opts) {
|
|
|
324
326
|
opts.qaRemediationAuthorization = undefined;
|
|
325
327
|
if (!fix.ok)
|
|
326
328
|
return pauseQaProtocol(opts, "builder-remediation-failed", fix.detail ?? "Builder QA remediation failed during restart recovery", fix.outcome === "needs-input" ? "needs-human" : "blocked");
|
|
329
|
+
if (!consumeRecoveryAfterOwnedFix(opts))
|
|
330
|
+
return pauseQaProtocol(opts, "builder-remediation-unconfirmed", "Builder remediation needs durable reconciliation before a fresh review", "needs-human");
|
|
327
331
|
const fixSummary = boundedBuilderSummary(fix.summary ?? fix.response ?? "Builder reported remediation complete");
|
|
328
332
|
opts.state.builderResponseHistory.push({ ticketId: opts.ticket.id, cycle: durableReport.reviewNumber, kind: "remediation", response: fix.response, summary: fixSummary });
|
|
329
333
|
opts.builderSummary = fix.response;
|
|
@@ -387,6 +391,8 @@ async function runIsolatedQaOwned(opts) {
|
|
|
387
391
|
opts.qaRemediationAuthorization = undefined;
|
|
388
392
|
if (!fix.ok)
|
|
389
393
|
return pauseQaProtocol(opts, "builder-remediation-failed", fix.detail ?? "Builder QA fix failed", fix.outcome === "needs-input" ? "needs-human" : "blocked");
|
|
394
|
+
if (!consumeRecoveryAfterOwnedFix(opts))
|
|
395
|
+
return pauseQaProtocol(opts, "builder-remediation-unconfirmed", "Builder remediation needs durable reconciliation before a fresh review", "needs-human");
|
|
390
396
|
const fixSummary = boundedBuilderSummary(fix.summary ?? fix.response ?? fix.detail ?? "Builder reported remediation complete");
|
|
391
397
|
opts.state.builderResponseHistory.push({
|
|
392
398
|
ticketId: opts.ticket.id,
|
|
@@ -1367,6 +1373,22 @@ function markQaRecoveryResolved(opts) {
|
|
|
1367
1373
|
db.close();
|
|
1368
1374
|
}
|
|
1369
1375
|
}
|
|
1376
|
+
function consumeRecoveryAfterOwnedFix(opts) {
|
|
1377
|
+
if (!opts.resumedRecovery)
|
|
1378
|
+
return true;
|
|
1379
|
+
const db = new WorkflowDb(opts.recovery.projectDir);
|
|
1380
|
+
try {
|
|
1381
|
+
if (!db.hasConfirmedQaRemediationForRecheck(opts.recovery.runId, opts.ticket.id))
|
|
1382
|
+
return false;
|
|
1383
|
+
// The validated report led to confirmed source-changing implementation.
|
|
1384
|
+
// Its recovery packet remains durable history, not the next review basis.
|
|
1385
|
+
opts.resumedRecovery = undefined;
|
|
1386
|
+
return true;
|
|
1387
|
+
}
|
|
1388
|
+
finally {
|
|
1389
|
+
db.close();
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1370
1392
|
function markQaRecoveryPendingBuilder(opts) {
|
|
1371
1393
|
markQaRecoveryAction(opts, "builder-remediation-pending");
|
|
1372
1394
|
}
|
|
@@ -1542,10 +1564,7 @@ function validateBoundaryReceipt(receipt, packet, successorHandle) {
|
|
|
1542
1564
|
|| resource.authoritative !== true || typeof resource.requiredForRecovery !== "boolean")) {
|
|
1543
1565
|
throw new Error("QA recovery acceptance receipt contains invalid resource metadata");
|
|
1544
1566
|
}
|
|
1545
|
-
|
|
1546
|
-
if (!receipt.resources.some((resource) => resource.label === required))
|
|
1547
|
-
throw new Error(`QA recovery acceptance receipt is missing ${required}`);
|
|
1548
|
-
}
|
|
1567
|
+
assertQaRecoveryHandoffInventory(receipt.resources);
|
|
1549
1568
|
if (receipt.inventoryDigest !== qaDigest("handoff-inventory", receipt.resources))
|
|
1550
1569
|
throw new Error("QA recovery acceptance receipt inventory digest mismatch");
|
|
1551
1570
|
const receiptDb = new WorkflowDb(packet.projectDir);
|
|
@@ -1566,6 +1585,13 @@ function validateBoundaryReceipt(receipt, packet, successorHandle) {
|
|
|
1566
1585
|
}
|
|
1567
1586
|
}
|
|
1568
1587
|
}
|
|
1588
|
+
/** Shared invariant for host staging and the actual QA boundary receipt. */
|
|
1589
|
+
export function assertQaRecoveryHandoffInventory(resources) {
|
|
1590
|
+
for (const required of ["continuity-checkpoint", "authoritative-run-state", "frozen-qa-action"]) {
|
|
1591
|
+
if (!resources.some(resource => resource.label === required))
|
|
1592
|
+
throw new Error(`QA recovery acceptance receipt is missing ${required}`);
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1569
1595
|
function effectiveTurnText(turn) { return turn.cleanedResponse ?? turn.text; }
|
|
1570
1596
|
const qaTurnBindings = new WeakMap();
|
|
1571
1597
|
function beginV2Review(opts, source, reviewedSnapshotPath, instruction, handle, attempt) {
|
|
@@ -1769,8 +1795,12 @@ async function sendDurableQaTurnOwned(adapter, instruction, events, slot) {
|
|
|
1769
1795
|
}
|
|
1770
1796
|
let result;
|
|
1771
1797
|
const providerStartedAt = Date.now();
|
|
1798
|
+
// Reading the sealed QA packet is ordinary confined read-only QA work.
|
|
1799
|
+
// Only formatting repair and ownership acceptance are response-only; the
|
|
1800
|
+
// first resource-inspection turn cannot truthfully read with tools disabled.
|
|
1801
|
+
const responseRepair = slot?.includes("correction") || (slot?.includes("acknowledgement") && slot.includes("repair"));
|
|
1772
1802
|
try {
|
|
1773
|
-
result = await adapter.sendTurn(instruction, slot === "session-initialization" ? { purpose: "initialization", responseOnly: true } :
|
|
1803
|
+
result = await adapter.sendTurn(instruction, slot === "session-initialization" ? { purpose: "initialization", responseOnly: true } : responseRepair ? { purpose: "response-repair", responseOnly: true } : undefined);
|
|
1774
1804
|
}
|
|
1775
1805
|
catch (error) {
|
|
1776
1806
|
const failedDb = new WorkflowDb(binding.projectDir);
|
|
@@ -1827,7 +1857,7 @@ async function sendDurableQaTurnOwned(adapter, instruction, events, slot) {
|
|
|
1827
1857
|
throw new Error(detail);
|
|
1828
1858
|
}
|
|
1829
1859
|
const completedAt = new Date().toISOString();
|
|
1830
|
-
const terminalEventObserved = Boolean(result.turnId
|
|
1860
|
+
const terminalEventObserved = Boolean(result.turnId && result.failure?.dispatchState !== "unknown"
|
|
1831
1861
|
&& events.some((event) => event.kind === "turn-complete" && (event.turnId ?? event.result.turnId) === result.turnId));
|
|
1832
1862
|
const receiptDb = new WorkflowDb(binding.projectDir);
|
|
1833
1863
|
try {
|
package/dist/sessionLifecycle.js
CHANGED
|
@@ -768,7 +768,7 @@ export class ThresholdCompactionController {
|
|
|
768
768
|
handoffGeneration() {
|
|
769
769
|
const db = new WorkflowDb(this.options.projectDir);
|
|
770
770
|
try {
|
|
771
|
-
return db.
|
|
771
|
+
return db.roleMutationLease(this.options.runId, this.options.role)?.generation ?? 0;
|
|
772
772
|
}
|
|
773
773
|
finally {
|
|
774
774
|
db.close();
|
package/dist/supervisedStart.js
CHANGED
|
@@ -3,8 +3,10 @@ import { fileURLToPath } from "node:url";
|
|
|
3
3
|
import { execFileSync, spawn } from "node:child_process";
|
|
4
4
|
import { closeSync, mkdirSync, openSync } from "node:fs";
|
|
5
5
|
import { join } from "node:path";
|
|
6
|
+
import { hostname } from "node:os";
|
|
6
7
|
import { DurableSupervisor } from "./supervisor.js";
|
|
7
8
|
import { WorkflowDb } from "./workflowDb.js";
|
|
9
|
+
import { processStartIdentity } from "./processIdentity.js";
|
|
8
10
|
import { cancelOwnedRuntimeProbes } from "./runtimeReadiness.js";
|
|
9
11
|
/** Worker IPC is created by the parent; an environment flag alone cannot bypass supervision. */
|
|
10
12
|
export function supervisedRunId() {
|
|
@@ -174,6 +176,12 @@ export async function superviseStart(projectDir, runId, policy, selection) {
|
|
|
174
176
|
return;
|
|
175
177
|
try {
|
|
176
178
|
db.acknowledgeBuildLaunchChild(authority, launch.token, child.pid);
|
|
179
|
+
// Record the incarnation before acknowledging the blocked launch gate.
|
|
180
|
+
// Any persistence failure follows the existing owned-child cleanup.
|
|
181
|
+
db.transitionSupervisor(runId, generation, {
|
|
182
|
+
checkpoint: db.getRun(runId)?.checkpoint ?? "preparing", event: "worker_started",
|
|
183
|
+
payload: { workerPid: child.pid, processStart: processStartIdentity(child.pid), host: hostname(), generation },
|
|
184
|
+
});
|
|
177
185
|
child.send("rafi-launch-ack", () => { });
|
|
178
186
|
clearTimeout(startup);
|
|
179
187
|
}
|
|
@@ -228,12 +236,18 @@ export async function superviseStart(projectDir, runId, policy, selection) {
|
|
|
228
236
|
const result = new Promise(resolve => {
|
|
229
237
|
child.once("error", error => {
|
|
230
238
|
clearInterval(watchdog);
|
|
239
|
+
exitCode = 1;
|
|
231
240
|
if (child.pid) {
|
|
232
241
|
stop();
|
|
233
242
|
return;
|
|
234
243
|
} // exit handler owns cleanup and settlement
|
|
235
|
-
|
|
236
|
-
|
|
244
|
+
try {
|
|
245
|
+
db.failBuildLaunch(authority, launch.token);
|
|
246
|
+
resolve({ kind: "failed", detail: error.message });
|
|
247
|
+
}
|
|
248
|
+
catch (failure) {
|
|
249
|
+
resolve({ kind: "failed", detail: `${error.message}; launch reconciliation failed: ${String(failure)}` });
|
|
250
|
+
}
|
|
237
251
|
});
|
|
238
252
|
child.once("exit", async (code, signal) => {
|
|
239
253
|
clearInterval(watchdog);
|
|
@@ -309,7 +323,13 @@ export async function superviseStart(projectDir, runId, policy, selection) {
|
|
|
309
323
|
process.once("SIGINT", stopInt);
|
|
310
324
|
process.once("SIGTERM", stopTerm);
|
|
311
325
|
try {
|
|
312
|
-
await supervisor.run();
|
|
326
|
+
const outcome = await supervisor.run();
|
|
327
|
+
if (outcome.kind === "failed") {
|
|
328
|
+
exitCode ||= 1;
|
|
329
|
+
console.error(`rafi: ${outcome.detail}`);
|
|
330
|
+
}
|
|
331
|
+
else if (outcome.kind === "waiting_for_human")
|
|
332
|
+
exitCode ||= 2;
|
|
313
333
|
}
|
|
314
334
|
finally {
|
|
315
335
|
process.off("SIGINT", stopInt);
|
package/dist/supervisor.js
CHANGED
|
@@ -80,6 +80,8 @@ export class DurableSupervisor {
|
|
|
80
80
|
if (pending.some(decision => !this.options.policy.continueIndependentTickets || !decision.interruptionId.startsWith("ticket:"))) {
|
|
81
81
|
state = { ...state, status: "waiting_for_human", workerPid: undefined, heartbeatAt: this.now().toISOString() };
|
|
82
82
|
saveState();
|
|
83
|
+
if (db.getRun(this.options.runId)?.status === "running")
|
|
84
|
+
db.transitionSupervisor(this.options.runId, state.workerGeneration, { status: "paused", checkpoint: "waiting-for-human", event: "worker_waiting_for_human" });
|
|
83
85
|
return { kind: "waiting_for_human" };
|
|
84
86
|
}
|
|
85
87
|
const checkpoint = this.options.checkpoint();
|
|
@@ -94,6 +96,19 @@ export class DurableSupervisor {
|
|
|
94
96
|
this.active = undefined;
|
|
95
97
|
state = { ...state, workerPid: undefined, heartbeatAt: this.now().toISOString() };
|
|
96
98
|
if (outcome.kind !== "crashed") {
|
|
99
|
+
if (outcome.kind === "failed" && db.getRun(this.options.runId)?.status === "running") {
|
|
100
|
+
const eligibility = db.preparationEligibility(this.options.runId);
|
|
101
|
+
db.transitionSupervisor(this.options.runId, state.workerGeneration, {
|
|
102
|
+
status: eligibility.eligible ? "failed" : "paused",
|
|
103
|
+
checkpoint: eligibility.eligible ? "worker-failed" : "worker-reconciliation-required",
|
|
104
|
+
event: "worker_exit_recorded", payload: { detail: outcome.detail, reasons: eligibility.reasons },
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
else if (db.getRun(this.options.runId)?.status === "running")
|
|
108
|
+
db.transitionSupervisor(this.options.runId, state.workerGeneration, {
|
|
109
|
+
status: "paused", checkpoint: outcome.kind === "waiting_for_human" && db.pendingHumanDecisions(this.options.runId).length ? "waiting-for-human" : "worker-exited-before-checkpoint",
|
|
110
|
+
event: "worker_exit_recorded", payload: { outcome: outcome.kind },
|
|
111
|
+
});
|
|
97
112
|
const status = outcome.kind === "completed" ? "stopped" : outcome.kind === "waiting_for_human" ? "waiting_for_human" : outcome.kind === "failed" ? "failed" : "stopped";
|
|
98
113
|
state = { ...state, status };
|
|
99
114
|
saveState();
|
|
@@ -111,6 +126,10 @@ export class DurableSupervisor {
|
|
|
111
126
|
if (state.checkpointRestarts >= checkpointLimit || state.runRestarts >= runLimit) {
|
|
112
127
|
state = { ...state, status: "failed" };
|
|
113
128
|
saveState();
|
|
129
|
+
if (db.getRun(this.options.runId)?.status === "running")
|
|
130
|
+
db.transitionSupervisor(this.options.runId, state.workerGeneration, {
|
|
131
|
+
status: "failed", checkpoint: "worker-restart-budget-exhausted", event: "worker_restart_budget_exhausted", payload: { checkpoint, detail: outcome.detail },
|
|
132
|
+
});
|
|
114
133
|
return { kind: "failed", detail: `worker restart budget exhausted at ${checkpoint}: ${outcome.detail}` };
|
|
115
134
|
}
|
|
116
135
|
state = { ...state, checkpointRestarts: state.checkpointRestarts + 1, runRestarts: state.runRestarts + 1 };
|
|
@@ -118,9 +137,23 @@ export class DurableSupervisor {
|
|
|
118
137
|
await this.sleep(Math.min(10_000, 250 * (2 ** Math.min(5, state.checkpointRestarts - 1))));
|
|
119
138
|
}
|
|
120
139
|
state = { ...state, status: "stopped", stopRequestedAt: this.now().toISOString(), workerPid: undefined };
|
|
140
|
+
if (db.getRun(this.options.runId)?.status === "running")
|
|
141
|
+
db.transitionSupervisor(this.options.runId, state.workerGeneration, { status: "paused", checkpoint: "worker-stopped", event: "supervisor_stop_recorded" });
|
|
121
142
|
saveState();
|
|
122
143
|
return { kind: "stopped" };
|
|
123
144
|
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
state = { ...state, status: "failed", workerPid: undefined, heartbeatAt: this.now().toISOString() };
|
|
147
|
+
saveState();
|
|
148
|
+
if (db.getRun(this.options.runId)?.status === "running") {
|
|
149
|
+
const eligibility = db.preparationEligibility(this.options.runId);
|
|
150
|
+
db.transitionSupervisor(this.options.runId, state.workerGeneration, { status: eligibility.eligible ? "failed" : "paused",
|
|
151
|
+
checkpoint: eligibility.eligible ? "worker-launch-failed" : "worker-reconciliation-required", event: "worker_launch_failed",
|
|
152
|
+
payload: { detail: String(error), reasons: eligibility.reasons },
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
throw error;
|
|
156
|
+
}
|
|
124
157
|
finally {
|
|
125
158
|
clearInterval(heartbeat);
|
|
126
159
|
try {
|
|
@@ -94,107 +94,111 @@ export function resolveTicketResetSelection(projectDir, requested, options = {},
|
|
|
94
94
|
}
|
|
95
95
|
export function applyResolvedTicketReset(projectDir, selection, actor = "user", now = new Date(), hooks = {}) {
|
|
96
96
|
const opened = open(projectDir);
|
|
97
|
-
const { config, paths,
|
|
98
|
-
|
|
99
|
-
const resetId = hooks.resetId ?? randomUUID();
|
|
100
|
-
const current = new Map(definitions.map((definition) => [definition.id, definition]));
|
|
101
|
-
const groupCatalog = db.listTicketGroups();
|
|
102
|
-
const snapshots = new Map(groupCatalog.flatMap((group) => group.members.map((member) => [member.ticketId, member.snapshot])));
|
|
103
|
-
const states = db.getAllStates();
|
|
104
|
-
const selectedIds = new Set(selection.ticketIds);
|
|
105
|
-
const currentRelatedRuns = recoverableBuildRuns(projectDir, now)
|
|
106
|
-
.filter((run) => run.tickets.some((ticketId) => selectedIds.has(ticketId)))
|
|
107
|
-
.map((run) => ({ runId: run.runId, status: run.status, tickets: [...run.tickets] }));
|
|
108
|
-
const comparable = { ...selection, relatedRuns: currentRelatedRuns };
|
|
109
|
-
delete comparable.inputFingerprint;
|
|
110
|
-
const actualFingerprint = selectionFingerprint(comparable, current, states, snapshots, groupCatalog);
|
|
111
|
-
if (actualFingerprint !== selection.inputFingerprint) {
|
|
112
|
-
db.close();
|
|
113
|
-
workflow.close();
|
|
114
|
-
throw new Error("ticket/group/reset inputs changed after preview; no reset was applied—preview the exact selection again");
|
|
115
|
-
}
|
|
116
|
-
const restore = selection.definitionRestorations.filter((item) => item.restore);
|
|
117
|
-
const restored = restore.map((item) => item.ticketId);
|
|
118
|
-
const restoredSet = new Set(restored);
|
|
119
|
-
const restoredDefinitions = restore.map((item) => snapshots.get(item.ticketId)?.definition);
|
|
120
|
-
if (restoredDefinitions.some((item) => !item)) {
|
|
121
|
-
db.close();
|
|
122
|
-
workflow.close();
|
|
123
|
-
throw new Error("a selected definition snapshot became unavailable");
|
|
124
|
-
}
|
|
125
|
-
const nextDefinitions = [...definitions, ...restoredDefinitions.filter((definition) => !current.has(definition.id))].sort((a, b) => a.order - b.order);
|
|
126
|
-
const definitionIssues = validateTicketDefs(nextDefinitions);
|
|
127
|
-
if (definitionIssues.length) {
|
|
128
|
-
db.close();
|
|
129
|
-
workflow.close();
|
|
130
|
-
throw new Error(`restored ticket definitions are invalid: ${definitionIssues.map((issue) => `${issue.path}: ${issue.message}`).join("; ")}`);
|
|
131
|
-
}
|
|
132
|
-
const resettable = selection.ticketIds.filter((ticketId) => current.has(ticketId) || restoredSet.has(ticketId));
|
|
133
|
-
const ignoredMissing = selection.ticketIds.filter((ticketId) => !current.has(ticketId) && !restoredSet.has(ticketId));
|
|
134
|
-
if (resettable.length === 0 && restored.length === 0) {
|
|
135
|
-
db.close();
|
|
136
|
-
workflow.close();
|
|
137
|
-
return { resetId, tickets: [], restored: [], ignoredMissing };
|
|
138
|
-
}
|
|
139
|
-
const stageDir = join(projectDir, ".rafi", "reset-stage", resetId);
|
|
140
|
-
const stagedDefinitions = join(stageDir, "tickets.yaml");
|
|
141
|
-
const stagedProgress = join(stageDir, "progress.md");
|
|
142
|
-
const definitionsBefore = existsSync(paths.tickets) ? readFileSync(paths.tickets) : undefined;
|
|
143
|
-
const progressBefore = existsSync(paths.progressDoc) ? readFileSync(paths.progressDoc) : undefined;
|
|
144
|
-
const oldStates = new Map([...new Set([...resettable, ...restored])].map((ticketId) => [ticketId, db.getState(ticketId)]));
|
|
145
|
-
const oldRecent = db.getRecentCompleted().filter((row) => resettable.includes(row.ticket_id));
|
|
146
|
-
const eventIds = [];
|
|
147
|
-
const run = workflow.createRun({ kind: "recovery", checkpoint: "group-reset-intent", originalWork: selection, remainingWork: { tickets: resettable }, state: { resetId } }, now);
|
|
148
|
-
const publication = workflow.beginPublication(run.runId, { operation: "group-ticket-reset", resetId, selection, stagedDefinitions, stagedProgress, definitionsTarget: paths.tickets, progressTarget: paths.progressDoc }, { definitions: digest(definitionsBefore), progress: digest(progressBefore) }, now);
|
|
97
|
+
const { config, paths, db } = opened;
|
|
98
|
+
let workflowHandle;
|
|
149
99
|
try {
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
100
|
+
const workflow = workflowHandle = new WorkflowDb(projectDir);
|
|
101
|
+
const resetId = hooks.resetId ?? randomUUID();
|
|
102
|
+
return workflow.withTrackerMutationAuthority(() => {
|
|
103
|
+
const definitions = loadTickets(paths.tickets);
|
|
104
|
+
const current = new Map(definitions.map((definition) => [definition.id, definition]));
|
|
105
|
+
const groupCatalog = db.listTicketGroups();
|
|
106
|
+
const snapshots = new Map(groupCatalog.flatMap((group) => group.members.map((member) => [member.ticketId, member.snapshot])));
|
|
107
|
+
const states = db.getAllStates();
|
|
108
|
+
const selectedIds = new Set(selection.ticketIds);
|
|
109
|
+
const currentRelatedRuns = recoverableBuildRuns(projectDir, now)
|
|
110
|
+
.filter((run) => run.tickets.some((ticketId) => selectedIds.has(ticketId)))
|
|
111
|
+
.map((run) => ({ runId: run.runId, status: run.status, tickets: [...run.tickets] }));
|
|
112
|
+
const comparable = { ...selection, relatedRuns: currentRelatedRuns };
|
|
113
|
+
delete comparable.inputFingerprint;
|
|
114
|
+
const actualFingerprint = selectionFingerprint(comparable, current, states, snapshots, groupCatalog);
|
|
115
|
+
if (actualFingerprint !== selection.inputFingerprint) {
|
|
116
|
+
throw new Error("ticket/group/reset inputs changed after preview; no reset was applied—preview the exact selection again");
|
|
117
|
+
}
|
|
118
|
+
const restore = selection.definitionRestorations.filter((item) => item.restore);
|
|
119
|
+
const restored = restore.map((item) => item.ticketId);
|
|
120
|
+
const restoredSet = new Set(restored);
|
|
121
|
+
const restoredDefinitions = restore.map((item) => snapshots.get(item.ticketId)?.definition);
|
|
122
|
+
if (restoredDefinitions.some((item) => !item)) {
|
|
123
|
+
throw new Error("a selected definition snapshot became unavailable");
|
|
124
|
+
}
|
|
125
|
+
const nextDefinitions = [...definitions, ...restoredDefinitions.filter((definition) => !current.has(definition.id))].sort((a, b) => a.order - b.order);
|
|
126
|
+
const definitionIssues = validateTicketDefs(nextDefinitions);
|
|
127
|
+
if (definitionIssues.length) {
|
|
128
|
+
throw new Error(`restored ticket definitions are invalid: ${definitionIssues.map((issue) => `${issue.path}: ${issue.message}`).join("; ")}`);
|
|
129
|
+
}
|
|
130
|
+
const resettable = selection.ticketIds.filter((ticketId) => current.has(ticketId) || restoredSet.has(ticketId));
|
|
131
|
+
const ignoredMissing = selection.ticketIds.filter((ticketId) => !current.has(ticketId) && !restoredSet.has(ticketId));
|
|
132
|
+
if (resettable.length === 0 && restored.length === 0) {
|
|
133
|
+
return { resetId, tickets: [], restored: [], ignoredMissing };
|
|
134
|
+
}
|
|
135
|
+
const stageDir = join(projectDir, ".rafi", "reset-stage", resetId);
|
|
136
|
+
const stagedDefinitions = join(stageDir, "tickets.yaml");
|
|
137
|
+
const stagedProgress = join(stageDir, "progress.md");
|
|
138
|
+
const definitionsBefore = existsSync(paths.tickets) ? readFileSync(paths.tickets) : undefined;
|
|
139
|
+
const progressBefore = existsSync(paths.progressDoc) ? readFileSync(paths.progressDoc) : undefined;
|
|
140
|
+
const oldStates = new Map([...new Set([...resettable, ...restored])].map((ticketId) => [ticketId, db.getState(ticketId)]));
|
|
141
|
+
const oldRecent = db.getRecentCompleted().filter((row) => resettable.includes(row.ticket_id));
|
|
142
|
+
const eventIds = [];
|
|
143
|
+
const run = workflow.createRun({ kind: "recovery", checkpoint: "group-reset-intent", originalWork: selection, remainingWork: { tickets: resettable }, state: { resetId } }, now);
|
|
144
|
+
let publication;
|
|
145
|
+
try {
|
|
146
|
+
publication = workflow.beginPublication(run.runId, { operation: "group-ticket-reset", resetId, selection, stagedDefinitions, stagedProgress, definitionsTarget: paths.tickets, progressTarget: paths.progressDoc }, { definitions: digest(definitionsBefore), progress: digest(progressBefore) }, now);
|
|
147
|
+
const stamp = now.toISOString();
|
|
148
|
+
db.transaction(() => {
|
|
149
|
+
for (const definition of restoredDefinitions)
|
|
150
|
+
if (!db.getState(definition.id))
|
|
151
|
+
db.upsertState(definition.id, pristineTicketState(definition.id, stamp, actor), stamp);
|
|
152
|
+
for (const row of selection.previewRows) {
|
|
153
|
+
if (!resettable.includes(row.ticketId)) {
|
|
154
|
+
eventIds.push(db.insertEvent({ timestamp: stamp, actor, ticket_id: row.ticketId, event_type: "ticket_reset_skipped_missing", old_status: row.status, new_status: row.status, summary: `Skipped missing definition ${row.ticketId}`, validation: null, evidence: null, payload_json: JSON.stringify({ reset_id: resetId, input_fingerprint: selection.inputFingerprint }) }));
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
const old = db.getState(row.ticketId)?.status ?? row.status;
|
|
158
|
+
db.upsertState(row.ticketId, pristineTicketState(row.ticketId, stamp, actor), stamp);
|
|
159
|
+
db.deleteRecentCompleted(row.ticketId);
|
|
160
|
+
eventIds.push(db.insertEvent({ timestamp: stamp, actor, ticket_id: row.ticketId, event_type: "ticket_reset", old_status: old, new_status: "planned", summary: `Reset ${row.ticketId} from approved immutable selection`, validation: null, evidence: null, payload_json: JSON.stringify({ reset_id: resetId, groups: selection.groups.map((group) => group.id), restored_definition: restoredSet.has(row.ticketId), input_fingerprint: selection.inputFingerprint }) }));
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
mkdirSync(stageDir, { recursive: true });
|
|
164
|
+
writeFileSync(stagedDefinitions, stringify({ tickets: nextDefinitions }, { lineWidth: 120 }), "utf8");
|
|
165
|
+
writeFileSync(stagedProgress, renderProgressDoc({ config, projectDir, ticketDefs: nextDefinitions, states: db.getAllStates(), db, now: stamp }), "utf8");
|
|
166
|
+
workflow.updatePublication(publication.transactionId, "staged", now);
|
|
167
|
+
workflow.updatePublication(publication.transactionId, "tracker_committed", now);
|
|
168
|
+
hooks.beforePublish?.();
|
|
169
|
+
atomicCopy(stagedDefinitions, paths.tickets);
|
|
170
|
+
writeProgressDoc(paths.progressDoc, readFileSync(stagedProgress, "utf8"), true);
|
|
171
|
+
workflow.updatePublication(publication.transactionId, "published", now);
|
|
172
|
+
workflow.updatePublication(publication.transactionId, "committed", now);
|
|
173
|
+
workflow.transition(run.runId, { status: "completed", checkpoint: "group-reset-committed", remainingWork: {}, state: { resetId, tickets: resettable, restored, ignoredMissing } }, now);
|
|
174
|
+
rmSync(stageDir, { recursive: true, force: true });
|
|
175
|
+
return { resetId, tickets: resettable, restored, ignoredMissing };
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
db.transaction(() => {
|
|
179
|
+
db.deleteEvents(eventIds);
|
|
180
|
+
for (const [ticketId, state] of oldStates)
|
|
181
|
+
state ? db.upsertState(ticketId, state, state.updated_at) : db.deleteState(ticketId);
|
|
182
|
+
for (const row of oldRecent)
|
|
183
|
+
db.upsertRecentCompleted(row);
|
|
184
|
+
});
|
|
185
|
+
restoreFile(paths.tickets, definitionsBefore);
|
|
186
|
+
restoreFile(paths.progressDoc, progressBefore);
|
|
187
|
+
if (publication)
|
|
188
|
+
workflow.updatePublication(publication.transactionId, "rolled_back", now);
|
|
189
|
+
workflow.transition(run.runId, { status: "failed", checkpoint: "group-reset-rolled-back", state: { resetId, error: error instanceof Error ? error.message : String(error) } }, now);
|
|
190
|
+
rmSync(stageDir, { recursive: true, force: true });
|
|
191
|
+
throw error;
|
|
164
192
|
}
|
|
165
193
|
});
|
|
166
|
-
mkdirSync(stageDir, { recursive: true });
|
|
167
|
-
writeFileSync(stagedDefinitions, stringify({ tickets: nextDefinitions }, { lineWidth: 120 }), "utf8");
|
|
168
|
-
writeFileSync(stagedProgress, renderProgressDoc({ config, projectDir, ticketDefs: nextDefinitions, states: db.getAllStates(), db, now: stamp }), "utf8");
|
|
169
|
-
workflow.updatePublication(publication.transactionId, "staged", now);
|
|
170
|
-
workflow.updatePublication(publication.transactionId, "tracker_committed", now);
|
|
171
|
-
hooks.beforePublish?.();
|
|
172
|
-
atomicCopy(stagedDefinitions, paths.tickets);
|
|
173
|
-
writeProgressDoc(paths.progressDoc, readFileSync(stagedProgress, "utf8"), true);
|
|
174
|
-
workflow.updatePublication(publication.transactionId, "published", now);
|
|
175
|
-
workflow.updatePublication(publication.transactionId, "committed", now);
|
|
176
|
-
workflow.transition(run.runId, { status: "completed", checkpoint: "group-reset-committed", remainingWork: {}, state: { resetId, tickets: resettable, restored, ignoredMissing } }, now);
|
|
177
|
-
rmSync(stageDir, { recursive: true, force: true });
|
|
178
|
-
return { resetId, tickets: resettable, restored, ignoredMissing };
|
|
179
|
-
}
|
|
180
|
-
catch (error) {
|
|
181
|
-
db.transaction(() => {
|
|
182
|
-
db.deleteEvents(eventIds);
|
|
183
|
-
for (const [ticketId, state] of oldStates)
|
|
184
|
-
state ? db.upsertState(ticketId, state, state.updated_at) : db.deleteState(ticketId);
|
|
185
|
-
for (const row of oldRecent)
|
|
186
|
-
db.upsertRecentCompleted(row);
|
|
187
|
-
});
|
|
188
|
-
restoreFile(paths.tickets, definitionsBefore);
|
|
189
|
-
restoreFile(paths.progressDoc, progressBefore);
|
|
190
|
-
workflow.updatePublication(publication.transactionId, "rolled_back", now);
|
|
191
|
-
workflow.transition(run.runId, { status: "failed", checkpoint: "group-reset-rolled-back", state: { resetId, error: error instanceof Error ? error.message : String(error) } }, now);
|
|
192
|
-
rmSync(stageDir, { recursive: true, force: true });
|
|
193
|
-
throw error;
|
|
194
194
|
}
|
|
195
195
|
finally {
|
|
196
|
-
|
|
197
|
-
|
|
196
|
+
try {
|
|
197
|
+
workflowHandle?.close();
|
|
198
|
+
}
|
|
199
|
+
finally {
|
|
200
|
+
db.close();
|
|
201
|
+
}
|
|
198
202
|
}
|
|
199
203
|
}
|
|
200
204
|
function selectGroups(groups, requested) {
|