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
package/dist/tickets/reset.js
CHANGED
|
@@ -61,82 +61,93 @@ export function resetTickets(projectDir, target, actor = "user", now = new Date(
|
|
|
61
61
|
if (ids.length === 0)
|
|
62
62
|
return { resetId: randomUUID(), tickets: [] };
|
|
63
63
|
const { config, paths, tickets, db } = open(projectDir);
|
|
64
|
-
|
|
65
|
-
const resetId = hooks.resetId ?? randomUUID();
|
|
66
|
-
if (ids.every((id) => db.getTicketEvents(id).some((event) => event.event_type === "ticket_reset" && event.payload_json?.includes(resetId)))) {
|
|
67
|
-
workflow.close();
|
|
68
|
-
db.close();
|
|
69
|
-
return { resetId, tickets: ids };
|
|
70
|
-
}
|
|
71
|
-
const stagePath = join(projectDir, ".rafi", "reset-stage", resetId, "progress.md");
|
|
72
|
-
const oldStates = new Map(ids.map((id) => [id, db.getState(id)]));
|
|
73
|
-
const oldRecentCompleted = db.getRecentCompleted().filter((row) => ids.includes(row.ticket_id));
|
|
74
|
-
const insertedEventIds = [];
|
|
75
|
-
const progressBefore = existsSync(paths.progressDoc) ? readFileSync(paths.progressDoc) : undefined;
|
|
76
|
-
const run = workflow.createRun({
|
|
77
|
-
kind: "recovery",
|
|
78
|
-
checkpoint: "ticket-reset-intent",
|
|
79
|
-
originalWork: { resetId, scope: preview.scope, tickets: ids, states: Object.fromEntries(oldStates) },
|
|
80
|
-
remainingWork: { tickets: ids },
|
|
81
|
-
state: { operation: "ticket-reset", progressDigest: digest(progressBefore) },
|
|
82
|
-
}, now);
|
|
83
|
-
const publication = workflow.beginPublication(run.runId, { operation: "ticket-reset", resetId, tickets: ids, stagePath, targetPath: paths.progressDoc }, { progressDigest: digest(progressBefore) }, now);
|
|
64
|
+
let workflowHandle;
|
|
84
65
|
try {
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
}
|
|
66
|
+
const workflow = workflowHandle = new WorkflowDb(projectDir);
|
|
67
|
+
const resetId = hooks.resetId ?? randomUUID();
|
|
68
|
+
if (ids.every((id) => db.getTicketEvents(id).some((event) => event.event_type === "ticket_reset" && event.payload_json?.includes(resetId)))) {
|
|
69
|
+
return { resetId, tickets: ids };
|
|
70
|
+
}
|
|
71
|
+
return workflow.withTrackerMutationAuthority(() => {
|
|
72
|
+
if (JSON.stringify(loadTickets(paths.tickets)) !== JSON.stringify(tickets))
|
|
73
|
+
throw new Error("Ticket definitions changed before reset authority was acquired; preview again");
|
|
74
|
+
const stagePath = join(projectDir, ".rafi", "reset-stage", resetId, "progress.md");
|
|
75
|
+
const oldStates = new Map(ids.map((id) => [id, db.getState(id)]));
|
|
76
|
+
const oldRecentCompleted = db.getRecentCompleted().filter((row) => ids.includes(row.ticket_id));
|
|
77
|
+
const insertedEventIds = [];
|
|
78
|
+
const progressBefore = existsSync(paths.progressDoc) ? readFileSync(paths.progressDoc) : undefined;
|
|
79
|
+
const run = workflow.createRun({
|
|
80
|
+
kind: "recovery",
|
|
81
|
+
checkpoint: "ticket-reset-intent",
|
|
82
|
+
originalWork: { resetId, scope: preview.scope, tickets: ids, states: Object.fromEntries(oldStates) },
|
|
83
|
+
remainingWork: { tickets: ids },
|
|
84
|
+
state: { operation: "ticket-reset", progressDigest: digest(progressBefore) },
|
|
85
|
+
}, now);
|
|
86
|
+
let publication;
|
|
87
|
+
try {
|
|
88
|
+
publication = workflow.beginPublication(run.runId, { operation: "ticket-reset", resetId, tickets: ids, stagePath, targetPath: paths.progressDoc }, { progressDigest: digest(progressBefore) }, now);
|
|
89
|
+
const stamp = now.toISOString();
|
|
90
|
+
db.transaction(() => {
|
|
91
|
+
for (const row of preview.tickets) {
|
|
92
|
+
db.upsertState(row.id, pristineTicketState(row.id, stamp, actor), stamp);
|
|
93
|
+
db.deleteRecentCompleted(row.id);
|
|
94
|
+
insertedEventIds.push(db.insertEvent({
|
|
95
|
+
timestamp: stamp,
|
|
96
|
+
actor,
|
|
97
|
+
ticket_id: row.id,
|
|
98
|
+
event_type: "ticket_reset",
|
|
99
|
+
old_status: oldStates.get(row.id)?.status ?? row.status,
|
|
100
|
+
new_status: "planned",
|
|
101
|
+
summary: `Reset ${row.id} to pristine active state`,
|
|
102
|
+
validation: null,
|
|
103
|
+
evidence: null,
|
|
104
|
+
payload_json: JSON.stringify({ reset_id: resetId, scope: preview.scope, cleared: row.cleared }),
|
|
105
|
+
}));
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
mkdirSync(dirname(stagePath), { recursive: true });
|
|
109
|
+
writeFileSync(stagePath, renderProgressDoc({ config, projectDir, ticketDefs: tickets, states: db.getAllStates(), db, now: stamp }), "utf8");
|
|
110
|
+
workflow.updatePublication(publication.transactionId, "staged", now);
|
|
111
|
+
workflow.updatePublication(publication.transactionId, "tracker_committed", now);
|
|
112
|
+
hooks.beforePublish?.();
|
|
113
|
+
writeProgressDoc(paths.progressDoc, readFileSync(stagePath, "utf8"), true);
|
|
114
|
+
workflow.updatePublication(publication.transactionId, "published", now);
|
|
115
|
+
workflow.updatePublication(publication.transactionId, "committed", now);
|
|
116
|
+
workflow.transition(run.runId, { status: "completed", checkpoint: "ticket-reset-committed", remainingWork: {}, state: { operation: "ticket-reset", resetId, tickets: ids } }, now);
|
|
117
|
+
rmSync(dirname(stagePath), { recursive: true, force: true });
|
|
118
|
+
return { resetId, tickets: ids };
|
|
102
119
|
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
db.transaction(() => {
|
|
118
|
-
db.deleteEvents(insertedEventIds);
|
|
119
|
-
for (const [id, state] of oldStates) {
|
|
120
|
-
if (state)
|
|
121
|
-
db.upsertState(id, state, state.updated_at);
|
|
120
|
+
catch (error) {
|
|
121
|
+
db.transaction(() => {
|
|
122
|
+
db.deleteEvents(insertedEventIds);
|
|
123
|
+
for (const [id, state] of oldStates) {
|
|
124
|
+
if (state)
|
|
125
|
+
db.upsertState(id, state, state.updated_at);
|
|
126
|
+
else
|
|
127
|
+
db.deleteState(id);
|
|
128
|
+
}
|
|
129
|
+
for (const row of oldRecentCompleted)
|
|
130
|
+
db.upsertRecentCompleted(row);
|
|
131
|
+
});
|
|
132
|
+
if (progressBefore)
|
|
133
|
+
writeProgressDoc(paths.progressDoc, progressBefore.toString("utf8"), true);
|
|
122
134
|
else
|
|
123
|
-
|
|
135
|
+
rmSync(paths.progressDoc, { force: true });
|
|
136
|
+
rmSync(dirname(stagePath), { recursive: true, force: true });
|
|
137
|
+
if (publication)
|
|
138
|
+
workflow.updatePublication(publication.transactionId, "rolled_back", now);
|
|
139
|
+
workflow.transition(run.runId, { status: "failed", checkpoint: "ticket-reset-rolled-back", state: { operation: "ticket-reset", resetId, error: error instanceof Error ? error.message : String(error) } }, now);
|
|
140
|
+
throw error;
|
|
124
141
|
}
|
|
125
|
-
for (const row of oldRecentCompleted)
|
|
126
|
-
db.upsertRecentCompleted(row);
|
|
127
142
|
});
|
|
128
|
-
if (progressBefore)
|
|
129
|
-
writeProgressDoc(paths.progressDoc, progressBefore.toString("utf8"), true);
|
|
130
|
-
else
|
|
131
|
-
rmSync(paths.progressDoc, { force: true });
|
|
132
|
-
rmSync(dirname(stagePath), { recursive: true, force: true });
|
|
133
|
-
workflow.updatePublication(publication.transactionId, "rolled_back", now);
|
|
134
|
-
workflow.transition(run.runId, { status: "failed", checkpoint: "ticket-reset-rolled-back", state: { operation: "ticket-reset", resetId, error: error instanceof Error ? error.message : String(error) } }, now);
|
|
135
|
-
throw error;
|
|
136
143
|
}
|
|
137
144
|
finally {
|
|
138
|
-
|
|
139
|
-
|
|
145
|
+
try {
|
|
146
|
+
workflowHandle?.close();
|
|
147
|
+
}
|
|
148
|
+
finally {
|
|
149
|
+
db.close();
|
|
150
|
+
}
|
|
140
151
|
}
|
|
141
152
|
}
|
|
142
153
|
/** Finish or roll back a reset publication left incomplete by process interruption. */
|
package/dist/workflowDb.d.ts
CHANGED
|
@@ -201,7 +201,6 @@ export interface QaReportRecordV2 {
|
|
|
201
201
|
createdAt: string;
|
|
202
202
|
updatedAt: string;
|
|
203
203
|
}
|
|
204
|
-
/** Authoritative persistence for every resumable workflow in a project. */
|
|
205
204
|
export declare class WorkflowDb {
|
|
206
205
|
readonly projectDir: string;
|
|
207
206
|
private readonly readinessAccess?;
|
|
@@ -212,6 +211,7 @@ export declare class WorkflowDb {
|
|
|
212
211
|
private coordinatorTransition;
|
|
213
212
|
private lineageTransition;
|
|
214
213
|
private workAuthority;
|
|
214
|
+
private readonly createdRecoveryIntents;
|
|
215
215
|
constructor(projectDir: string, path?: string, readinessAccess?: {
|
|
216
216
|
probeId: string;
|
|
217
217
|
} | {
|
|
@@ -254,6 +254,9 @@ export declare class WorkflowDb {
|
|
|
254
254
|
/** Atomic local changes only; never await provider or filesystem work here. */
|
|
255
255
|
atomic<T>(work: () => T): T;
|
|
256
256
|
buildAdmission(): BuildAdmission | undefined;
|
|
257
|
+
/** Short synchronous tracker mutations share the build fence without retiring
|
|
258
|
+
* provider receipts or granting authority to resume implementation. */
|
|
259
|
+
withTrackerMutationAuthority<T>(work: () => T): T;
|
|
257
260
|
private assertNoLegacyBuildOwner;
|
|
258
261
|
private assertExecutionProtocol;
|
|
259
262
|
acquireBuildAdmission(runId: string, phase: BuildAdmission["phase"]): BuildAdmission;
|
|
@@ -270,6 +273,10 @@ export declare class WorkflowDb {
|
|
|
270
273
|
acknowledgeBuildLaunchChild(authority: BuildAdmission, token: string, pid: number): void;
|
|
271
274
|
reconcileBuildLaunches(runId: string, original?: BuildAdmission): "retired" | "claimed" | "unknown";
|
|
272
275
|
buildLaunch(token: string): BuildLaunch | undefined;
|
|
276
|
+
/** A supervisor's waiting exit may represent a successful partial QA boundary.
|
|
277
|
+
* Require this launch's clean worker exit and verified cleanup, never just QA. */
|
|
278
|
+
successfulPartialQaRecovery(runId: string, ticketId: string, launchToken: string): boolean;
|
|
279
|
+
waitForSuccessfulPartialQaRecovery(runId: string, ticketId: string, launchToken: string): Promise<boolean>;
|
|
273
280
|
preparationSuccessor(runId: string): string;
|
|
274
281
|
reservePreparationRetry(predecessor: string, args: string[], expectedStartArgs?: string): {
|
|
275
282
|
runId: string;
|
|
@@ -310,6 +317,9 @@ export declare class WorkflowDb {
|
|
|
310
317
|
state?: Record<string, unknown>;
|
|
311
318
|
legacy?: boolean;
|
|
312
319
|
}, now?: Date): WorkflowRunSnapshot;
|
|
320
|
+
/** Record failure of an intent this connection just created, before recovery
|
|
321
|
+
* authority was acquired. This cannot write build execution or publications. */
|
|
322
|
+
pauseOwnStartOverIntent(runId: string, error: unknown): void;
|
|
313
323
|
getRun(runId: string): WorkflowRunSnapshot | undefined;
|
|
314
324
|
/** Publication intents are immutable; mutable tracker snapshots are not approval evidence. */
|
|
315
325
|
approvedPopulationDefinitions(): unknown[];
|
|
@@ -347,6 +357,7 @@ export declare class WorkflowDb {
|
|
|
347
357
|
sessionUsageSamples(runId: string, role?: "builder" | "qa"): SessionUsageSample[];
|
|
348
358
|
acknowledgeSettings(ack: LiveSettingsAcknowledgment): void;
|
|
349
359
|
settingsAcknowledgments(revision: number): LiveSettingsAcknowledgment[];
|
|
360
|
+
settingsAdoptionState(runId: string, role: "builder" | "qa"): "active" | "deferred" | "unknown";
|
|
350
361
|
appendContinuityEvent(input: {
|
|
351
362
|
runId: string;
|
|
352
363
|
role: "builder" | "qa" | "host";
|
|
@@ -494,6 +505,9 @@ export declare class WorkflowDb {
|
|
|
494
505
|
recordQaHandoffReceipt(receipt: HandoffAcceptanceReceiptV2): void;
|
|
495
506
|
qaHandoffReceipt(operationId: string): HandoffAcceptanceReceiptV2 | undefined;
|
|
496
507
|
beginQaTurn(intent: QaTurnIntentV2): void;
|
|
508
|
+
/** Retire an interrupted, proven read-only review transport, never a QA pass
|
|
509
|
+
* or Builder remediation. A fresh immutable review must still run afterward. */
|
|
510
|
+
reconcileInterruptedQaDispatch(runId: string, ticketId: string): boolean;
|
|
497
511
|
commitQaTurnIntent(intent: QaTurnIntentV2, expectedRevision: number): QaReducerStateV2;
|
|
498
512
|
finishQaTurn(receipt: QaTurnReceiptV2): void;
|
|
499
513
|
recordQaReport(input: Omit<QaReportRecordV2, "reportOccurrenceId" | "disposition" | "createdAt" | "updatedAt">, findingIds: string[], now?: Date): QaReportRecordV2;
|
|
@@ -521,6 +535,7 @@ export declare class WorkflowDb {
|
|
|
521
535
|
authorizeContractQaWaiver(runId: string, workId: string, decisionId: string): string;
|
|
522
536
|
commitQaWaiver(runId: string, ticketId: string, expectedRevision: number, reason: string, now?: Date, authorizationRef?: string): QaReducerStateV2;
|
|
523
537
|
builderRemediationReceipt(operationId: string): BuilderRemediationReceiptV2 | BuilderRemediationReceiptV3 | undefined;
|
|
538
|
+
hasConfirmedQaRemediationForRecheck(runId: string, ticketId: string): boolean;
|
|
524
539
|
recordBuilderRemediationReceipt(receipt: BuilderRemediationReceiptV2 | BuilderRemediationReceiptV3): void;
|
|
525
540
|
commitBuilderRemediationSuccess(receipt: BuilderRemediationReceiptV2 | BuilderRemediationReceiptV3, expectedRevision: number, now?: Date): QaReducerStateV2;
|
|
526
541
|
commitQaRemediationOutcome(input: {
|
package/dist/workflowDb.js
CHANGED
|
@@ -15,11 +15,15 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
15
15
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
16
16
|
import { hostname } from "node:os";
|
|
17
17
|
import { dirname, join, resolve } from "node:path";
|
|
18
|
+
import { execFileSync } from "node:child_process";
|
|
18
19
|
import Database from "better-sqlite3";
|
|
19
20
|
import { validateQaFailureReport } from "rafi-spec";
|
|
20
21
|
import { migrateQaHandback, registerHandbackWriter, reportOccurrenceId } from "./qaHandbackMigration.js";
|
|
21
22
|
import { providerSessionKey } from "./sessionIdentity.js";
|
|
22
|
-
import {
|
|
23
|
+
import { captureFrozenQaSource } from "./qaSnapshot.js";
|
|
24
|
+
import { loadTicketsConfig, resolveTicketPaths } from "./tickets/config.js";
|
|
25
|
+
import { loadTickets } from "./tickets/ticketLoader.js";
|
|
26
|
+
import { processGroupQuiescent, taggedProcesses, classifyProcess, processStartIdentity } from "./processIdentity.js";
|
|
23
27
|
import { initialQaReducerState, qaDigest, reduceQaState, } from "./qaProtocolV2.js";
|
|
24
28
|
export const WORKFLOW_DB_FILE = ".rafi/recovery.sqlite3";
|
|
25
29
|
/** Read the current project lease without creating or migrating recovery state. */
|
|
@@ -63,6 +67,41 @@ export function heartbeatCurrentWorkflowLease(projectDir, lease, now = new Date(
|
|
|
63
67
|
}
|
|
64
68
|
}
|
|
65
69
|
/** Authoritative persistence for every resumable workflow in a project. */
|
|
70
|
+
function partialQaRecoveryProcessProof(db, runId, ticketId, launchToken) {
|
|
71
|
+
const run = db.getRun(runId);
|
|
72
|
+
const launch = db.buildLaunch(launchToken);
|
|
73
|
+
const supervisor = db.supervisorState(runId);
|
|
74
|
+
if (!run || run.status !== "paused" || run.state.status !== "recoverable" || run.checkpoint !== "qa-recovery-complete"
|
|
75
|
+
|| !launch || launch.runId !== runId || launch.state !== "claimed" || !launch.claimant
|
|
76
|
+
|| !supervisor || supervisor.status !== "waiting_for_human" || supervisor.workerPid
|
|
77
|
+
|| launch.role !== "coordinator" || launch.claimant.pid !== supervisor.pid || launch.claimant.processStart !== supervisor.processStart
|
|
78
|
+
|| !["completed", "waived"].includes(db.qaTicketHead(runId, ticketId).state)
|
|
79
|
+
|| !(run.state.progress?.completedTickets?.includes(ticketId))
|
|
80
|
+
|| db.pendingHumanDecisions(runId).length || db.readinessProcesses(runId).length
|
|
81
|
+
|| db.unresolvedRoleDispatches(runId, "builder").length || db.unresolvedRoleDispatches(runId, "qa").length)
|
|
82
|
+
return undefined;
|
|
83
|
+
const events = db.events(runId);
|
|
84
|
+
const exit = events.filter(event => event.type === "worker_exit").at(-1);
|
|
85
|
+
const started = events.filter(event => event.type === "worker_started").at(-1);
|
|
86
|
+
const exitProof = exit?.payload;
|
|
87
|
+
const startProof = started?.payload;
|
|
88
|
+
if (!exit || !started || exit.at < launch.createdAt || started.at < launch.createdAt
|
|
89
|
+
|| !exitProof || !startProof || exitProof.code !== 0 || exitProof.signal != null || exitProof.stopRequested !== false
|
|
90
|
+
|| exitProof.generation !== supervisor.workerGeneration || startProof.generation !== exitProof.generation)
|
|
91
|
+
return undefined;
|
|
92
|
+
const worker = started.payload;
|
|
93
|
+
if (!worker.workerPid || !worker.processStart || !worker.host)
|
|
94
|
+
return undefined;
|
|
95
|
+
return { worker: { pid: worker.workerPid, processStart: worker.processStart, host: worker.host }, claimant: launch.claimant,
|
|
96
|
+
launchToken, checkpoint: run.checkpoint, supervisorGeneration: supervisor.generation,
|
|
97
|
+
workerGeneration: supervisor.workerGeneration, startedAt: started.at, exitedAt: exit.at,
|
|
98
|
+
startedSequence: started.sequence, exitedSequence: exit.sequence };
|
|
99
|
+
}
|
|
100
|
+
function partialQaProcessesSettled(proof) {
|
|
101
|
+
return classifyProcess(proof.worker.pid, proof.worker.processStart, proof.worker.host).state === "dead"
|
|
102
|
+
&& processGroupQuiescent(proof.worker.pid)
|
|
103
|
+
&& classifyProcess(proof.claimant.pid, proof.claimant.processStart, proof.claimant.host).state === "dead";
|
|
104
|
+
}
|
|
66
105
|
export class WorkflowDb {
|
|
67
106
|
projectDir;
|
|
68
107
|
readinessAccess;
|
|
@@ -73,6 +112,7 @@ export class WorkflowDb {
|
|
|
73
112
|
coordinatorTransition = 0;
|
|
74
113
|
lineageTransition = 0;
|
|
75
114
|
workAuthority = 0;
|
|
115
|
+
createdRecoveryIntents = new Set();
|
|
76
116
|
constructor(projectDir, path = join(resolve(projectDir), WORKFLOW_DB_FILE), readinessAccess) {
|
|
77
117
|
this.projectDir = projectDir;
|
|
78
118
|
this.readinessAccess = readinessAccess;
|
|
@@ -290,6 +330,70 @@ export class WorkflowDb {
|
|
|
290
330
|
/** Atomic local changes only; never await provider or filesystem work here. */
|
|
291
331
|
atomic(work) { return this.db.transaction(work).immediate(); }
|
|
292
332
|
buildAdmission() { return readAdmission(this.db); }
|
|
333
|
+
/** Short synchronous tracker mutations share the build fence without retiring
|
|
334
|
+
* provider receipts or granting authority to resume implementation. */
|
|
335
|
+
withTrackerMutationAuthority(work) {
|
|
336
|
+
if (this.writerAuthority) {
|
|
337
|
+
this.assertBuildAdmission(this.writerAuthority);
|
|
338
|
+
return work();
|
|
339
|
+
}
|
|
340
|
+
let authority;
|
|
341
|
+
let lease;
|
|
342
|
+
let intent;
|
|
343
|
+
try {
|
|
344
|
+
const prior = this.buildAdmission();
|
|
345
|
+
if (prior) {
|
|
346
|
+
authority = this.acquireBuildRecoveryAdmission(prior.runId);
|
|
347
|
+
lease = this.acquireLease(prior.runId);
|
|
348
|
+
}
|
|
349
|
+
else {
|
|
350
|
+
this.atomic(() => {
|
|
351
|
+
this.assertNoLegacyBuildOwner();
|
|
352
|
+
for (const owner of this.runningSupervisors()) {
|
|
353
|
+
if (owner.state.pid && classifyProcess(owner.state.pid, owner.state.processStart).state !== "dead")
|
|
354
|
+
throw new Error("A live or unverified supervisor must stop before tracker reset");
|
|
355
|
+
}
|
|
356
|
+
for (const row of this.db.prepare("SELECT DISTINCT run_id FROM build_owned_processes WHERE state<>'quiescent'").all()) {
|
|
357
|
+
if (this.unresolvedPreparationProcesses(row.run_id).length)
|
|
358
|
+
throw new Error("Owned preparation descendants require reconciliation before tracker reset");
|
|
359
|
+
}
|
|
360
|
+
intent = this.createRun({ kind: "recovery", checkpoint: "tracker-mutation-intent", state: { operation: "tracker-mutation" } });
|
|
361
|
+
lease = this.acquireLease(intent.runId);
|
|
362
|
+
authority = this.admitLeasedBuild(lease);
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
const result = work();
|
|
366
|
+
if (intent)
|
|
367
|
+
this.transition(intent.runId, { status: "completed", checkpoint: "tracker-mutation-completed" });
|
|
368
|
+
return result;
|
|
369
|
+
}
|
|
370
|
+
catch (error) {
|
|
371
|
+
if (intent && this.getRun(intent.runId) && authority && this.buildAdmission()?.token === authority.token) {
|
|
372
|
+
this.transition(intent.runId, { status: "failed", checkpoint: "tracker-mutation-failed", payload: { error: error instanceof Error ? error.message : String(error) } });
|
|
373
|
+
}
|
|
374
|
+
throw error;
|
|
375
|
+
}
|
|
376
|
+
finally {
|
|
377
|
+
try {
|
|
378
|
+
if (lease) {
|
|
379
|
+
this.releaseLease(lease);
|
|
380
|
+
// Acquisition can have been rolled back by the outer transaction.
|
|
381
|
+
// Drop only this invocation's cached token, never a replacement owner.
|
|
382
|
+
if (this.currentLease()?.owner !== lease.owner)
|
|
383
|
+
forgetOriginalLease(this.projectDir, lease);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
finally {
|
|
387
|
+
if (authority && this.buildAdmission()?.token === authority.token)
|
|
388
|
+
this.releaseBuildAdmission(authority);
|
|
389
|
+
else if (authority) {
|
|
390
|
+
forgetBuildAuthority(authority);
|
|
391
|
+
if (this.writerAuthority?.token === authority.token)
|
|
392
|
+
this.writerAuthority = undefined;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
293
397
|
assertNoLegacyBuildOwner() {
|
|
294
398
|
for (const row of this.db.prepare("SELECT run_id,state_json FROM workflow_runs WHERE kind='build' AND legacy=1").all()) {
|
|
295
399
|
const state = parseJson(row.state_json);
|
|
@@ -419,6 +523,29 @@ export class WorkflowDb {
|
|
|
419
523
|
});
|
|
420
524
|
}
|
|
421
525
|
buildLaunch(token) { return readLaunch(this.db, token); }
|
|
526
|
+
/** A supervisor's waiting exit may represent a successful partial QA boundary.
|
|
527
|
+
* Require this launch's clean worker exit and verified cleanup, never just QA. */
|
|
528
|
+
successfulPartialQaRecovery(runId, ticketId, launchToken) {
|
|
529
|
+
const proof = partialQaRecoveryProcessProof(this, runId, ticketId, launchToken);
|
|
530
|
+
return Boolean(proof && partialQaProcessesSettled(proof));
|
|
531
|
+
}
|
|
532
|
+
async waitForSuccessfulPartialQaRecovery(runId, ticketId, launchToken) {
|
|
533
|
+
// Clean exits can precede native descendant/inventory settlement. Recheck
|
|
534
|
+
// only an already validated exact boundary; never kill or relax ownership.
|
|
535
|
+
const initial = partialQaRecoveryProcessProof(this, runId, ticketId, launchToken);
|
|
536
|
+
if (!initial || process.platform === "win32")
|
|
537
|
+
return false;
|
|
538
|
+
const deadline = Date.now() + 3000;
|
|
539
|
+
do {
|
|
540
|
+
const proof = partialQaRecoveryProcessProof(this, runId, ticketId, launchToken);
|
|
541
|
+
if (!proof || JSON.stringify(proof) !== JSON.stringify(initial))
|
|
542
|
+
return false;
|
|
543
|
+
if (partialQaProcessesSettled(proof))
|
|
544
|
+
return true;
|
|
545
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
546
|
+
} while (Date.now() < deadline);
|
|
547
|
+
return false;
|
|
548
|
+
}
|
|
422
549
|
preparationSuccessor(runId) {
|
|
423
550
|
const seen = new Set();
|
|
424
551
|
let current = runId;
|
|
@@ -714,8 +841,26 @@ export class WorkflowDb {
|
|
|
714
841
|
}
|
|
715
842
|
this.insertEvent(run.runId, "run_created", run.checkpoint, { kind: run.kind }, at);
|
|
716
843
|
})();
|
|
844
|
+
if (run.kind === "recovery")
|
|
845
|
+
this.createdRecoveryIntents.add(run.runId);
|
|
717
846
|
return run;
|
|
718
847
|
}
|
|
848
|
+
/** Record failure of an intent this connection just created, before recovery
|
|
849
|
+
* authority was acquired. This cannot write build execution or publications. */
|
|
850
|
+
pauseOwnStartOverIntent(runId, error) {
|
|
851
|
+
const run = this.getRun(runId);
|
|
852
|
+
if (!this.createdRecoveryIntents.has(runId) || run?.kind !== "recovery" || run.state.operation !== "build-start-over"
|
|
853
|
+
|| run.checkpoint !== "start-over-inventory" || run.state.gitPreserved || this.operations(runId).length
|
|
854
|
+
|| this.incompletePublications().some(publication => publication.runId === runId))
|
|
855
|
+
throw new Error("Recovery intent is outside this connection's unstarted setup scope");
|
|
856
|
+
this.coordinatorTransition++;
|
|
857
|
+
try {
|
|
858
|
+
this.transition(runId, { status: "paused", checkpoint: "start-over-setup-paused", state: { ...run.state, error: error instanceof Error ? error.message : String(error) } });
|
|
859
|
+
}
|
|
860
|
+
finally {
|
|
861
|
+
this.coordinatorTransition--;
|
|
862
|
+
}
|
|
863
|
+
}
|
|
719
864
|
getRun(runId) {
|
|
720
865
|
const row = this.db.prepare("SELECT * FROM workflow_runs WHERE run_id=?").get(runId);
|
|
721
866
|
return row ? rowToRun(row) : undefined;
|
|
@@ -849,6 +994,21 @@ export class WorkflowDb {
|
|
|
849
994
|
const rows = this.db.prepare("SELECT * FROM live_settings_acknowledgments WHERE revision=? ORDER BY acknowledged_at").all(revision);
|
|
850
995
|
return rows.map((row) => ({ runId: row.run_id, role: row.role, ...(row.provider_session_id ? { providerSessionId: row.provider_session_id } : {}), ...(row.session_key ? { sessionKey: row.session_key } : {}), ...(row.session_ref_json ? { sessionRef: parseJson(row.session_ref_json) } : {}), revision: row.revision, acknowledgedAt: row.acknowledged_at }));
|
|
851
996
|
}
|
|
997
|
+
settingsAdoptionState(runId, role) {
|
|
998
|
+
const admission = this.buildAdmission();
|
|
999
|
+
const lease = this.currentLease();
|
|
1000
|
+
const owner = admission?.runId === runId ? admission : lease?.runId === runId ? lease : undefined;
|
|
1001
|
+
if (!owner)
|
|
1002
|
+
return "deferred";
|
|
1003
|
+
const liveness = classifyProcess(owner.pid, owner.processStart, owner.host).state;
|
|
1004
|
+
if (liveness === "unknown")
|
|
1005
|
+
return "unknown";
|
|
1006
|
+
if (liveness !== "live" || this.getRun(runId)?.status !== "running" || this.pendingHumanDecisions(runId).length)
|
|
1007
|
+
return "deferred";
|
|
1008
|
+
// Historical role/session records are not proof of an active role. A
|
|
1009
|
+
// submitted turn can adopt saved settings at its next safe boundary.
|
|
1010
|
+
return this.unresolvedRoleDispatches(runId, role).some(operation => operation.status === "in_progress") ? "active" : "deferred";
|
|
1011
|
+
}
|
|
852
1012
|
appendContinuityEvent(input, now = new Date()) {
|
|
853
1013
|
this.ensureRun(input.runId, "build", now);
|
|
854
1014
|
const at = now.toISOString();
|
|
@@ -893,7 +1053,7 @@ export class WorkflowDb {
|
|
|
893
1053
|
this.refreshRunContinuityHead(input.runId, input.authoritativeStateRevision, at);
|
|
894
1054
|
this.insertEvent(input.runId, "continuity_checkpoint", `continuity:${input.role}`, { checkpointId: checkpoint.checkpointId, digest, state: checkpoint.state }, at);
|
|
895
1055
|
return checkpoint;
|
|
896
|
-
})();
|
|
1056
|
+
}).immediate();
|
|
897
1057
|
}
|
|
898
1058
|
continuityCheckpoints(runId, role) {
|
|
899
1059
|
const rows = role
|
|
@@ -1578,6 +1738,90 @@ export class WorkflowDb {
|
|
|
1578
1738
|
.run(intent.runId, intent.ticketId, intent.reviewNumber, intent.sessionGeneration, intent.slot, intent.operationId, at);
|
|
1579
1739
|
})();
|
|
1580
1740
|
}
|
|
1741
|
+
/** Retire an interrupted, proven read-only review transport, never a QA pass
|
|
1742
|
+
* or Builder remediation. A fresh immutable review must still run afterward. */
|
|
1743
|
+
reconcileInterruptedQaDispatch(runId, ticketId) {
|
|
1744
|
+
const dispatches = this.unresolvedRoleDispatches(runId, "qa");
|
|
1745
|
+
if (!dispatches.length)
|
|
1746
|
+
return false;
|
|
1747
|
+
const lease = this.currentLease();
|
|
1748
|
+
if (lease?.runId !== runId || lease.pid !== process.pid)
|
|
1749
|
+
throw new Error("QA reconciliation requires the original build recovery lease");
|
|
1750
|
+
const worker = this.events(runId).filter(event => event.type === "worker_started").at(-1)?.payload;
|
|
1751
|
+
if (!worker?.workerPid || classifyProcess(worker.workerPid, worker.processStart, worker.host).state !== "dead" || !processGroupQuiescent(worker.workerPid)
|
|
1752
|
+
|| this.readinessProcesses().length || this.unresolvedRoleDispatches(runId, "builder").length)
|
|
1753
|
+
throw new Error("Interrupted QA and all owned provider processes must be verified stopped before a fresh review");
|
|
1754
|
+
const head = this.qaTicketHead(runId, ticketId);
|
|
1755
|
+
if (!["turn-intended", "turn-uncertain", "operator-menu"].includes(head.state))
|
|
1756
|
+
throw new Error("QA dispatch is not an interrupted review boundary");
|
|
1757
|
+
const run = this.getRun(runId)?.state;
|
|
1758
|
+
if (!run?.tickets?.includes(ticketId) || run.progress?.completedTickets?.includes(ticketId))
|
|
1759
|
+
throw new Error("QA recovery cannot reopen completed or unapproved tickets");
|
|
1760
|
+
const definition = loadTickets(resolveTicketPaths(loadTicketsConfig(this.projectDir), this.projectDir).tickets).find(ticket => ticket.id === ticketId);
|
|
1761
|
+
if (!definition || qaDigest("admitted-requirements", definition) !== this.assertAdmittedWork(runId, ticketId).requirementsDigest)
|
|
1762
|
+
throw new Error("QA recovery requirements changed; approval is required");
|
|
1763
|
+
const owner = this.roleMutationLease(runId, "qa");
|
|
1764
|
+
// A cancellation can have a durable host terminal receipt while the
|
|
1765
|
+
// provider dispatch is still unknown. Exact frozen-turn bindings remain
|
|
1766
|
+
// required; the old receipt is never promoted to an accepted QA result.
|
|
1767
|
+
const rows = this.db.prepare("SELECT status,intent_json FROM qa_turns WHERE run_id=? AND ticket_id=? AND status IN ('intended','uncertain','unknown','completed')").all(runId, ticketId);
|
|
1768
|
+
const turns = rows.flatMap(row => {
|
|
1769
|
+
const turn = parseJson(row.intent_json);
|
|
1770
|
+
if (row.status === "completed" && !dispatches.some(dispatch => {
|
|
1771
|
+
const result = dispatch.result;
|
|
1772
|
+
return result?.isError === true && result.failure?.dispatchState === "unknown"
|
|
1773
|
+
&& dispatch.intent.instructionDigest === turn.instructionDigest;
|
|
1774
|
+
}))
|
|
1775
|
+
return [];
|
|
1776
|
+
return [turn];
|
|
1777
|
+
});
|
|
1778
|
+
const bindings = dispatches.map(dispatch => {
|
|
1779
|
+
const intent = dispatch.intent;
|
|
1780
|
+
const ref = intent.sessionRef;
|
|
1781
|
+
if (!ref || !owner?.sessionKey || ref.role !== "qa" || ref.stream !== "qa" || !Number.isSafeInteger(ref.generation) || ref.generation < 0
|
|
1782
|
+
|| !ref.configRoot || canonicalProject(ref.configRoot) !== canonicalProject(this.projectDir) || !ref.validatedAt || Number.isNaN(Date.parse(ref.validatedAt))
|
|
1783
|
+
|| ref.source !== "observed" || providerSessionKey(ref) !== owner.sessionKey)
|
|
1784
|
+
throw new Error("QA dispatch has no validated owner binding");
|
|
1785
|
+
const turn = turns.find(turn => turn.reviewNumber === head.reviewNumber && turn.instructionDigest === intent.instructionDigest
|
|
1786
|
+
&& turn.providerSession.sessionId === ref.sessionId && turn.providerSession.provider === ref.provider && turn.providerSession.generation === ref.generation
|
|
1787
|
+
&& turn.providerSession.cwd === ref.cwd && turn.providerSession.configRoot === ref.configRoot);
|
|
1788
|
+
if (!turn || turn.sourceStateDigest !== head.sourceStateDigest || turn.reviewBasisDigest !== head.reviewBasisDigest)
|
|
1789
|
+
throw new Error("QA dispatch does not match the frozen review turn");
|
|
1790
|
+
const sessions = this.db.prepare("SELECT session_json FROM qa_sessions WHERE run_id=? AND ticket_id=? AND generation=? AND source_state_digest=? AND review_basis_digest=?").all(runId, ticketId, ref.generation, turn.sourceStateDigest, turn.reviewBasisDigest);
|
|
1791
|
+
const session = sessions.map(row => parseJson(row.session_json)).find(session => providerSessionKey(session.session) === owner.sessionKey && session.confinement);
|
|
1792
|
+
const confinement = session?.confinement;
|
|
1793
|
+
if (!confinement)
|
|
1794
|
+
throw new Error("Interrupted QA lacks a retained read-only confinement proof");
|
|
1795
|
+
const { digest, ...fields } = confinement;
|
|
1796
|
+
if (fields.version !== 2 || fields.sourceMode !== "read-only" || fields.scratchMode !== "isolated" || !["none", "user"].includes(String(fields.settingsSources))
|
|
1797
|
+
|| !["disabled", "provider-required"].includes(String(fields.networkMode)) || !/^[a-f0-9]{64}$/.test(String(fields.environmentDigest)) || !/^[a-f0-9]{64}$/.test(String(fields.policyDigest)) || digest !== qaDigest("qa-confinement", fields))
|
|
1798
|
+
throw new Error("Interrupted QA confinement is invalid");
|
|
1799
|
+
const sourceRow = this.db.prepare("SELECT state_json FROM qa_source_states WHERE run_id=? AND ticket_id=? AND digest=?").get(runId, ticketId, turn.sourceStateDigest);
|
|
1800
|
+
const frozen = sourceRow ? parseJson(sourceRow.state_json) : undefined;
|
|
1801
|
+
if (!frozen || frozen.version !== 2 || frozen.runId !== runId || frozen.ticketId !== ticketId || frozen.digest !== turn.sourceStateDigest || !/^[a-f0-9]{64}$/.test(frozen.contentDigest) || !/^[a-f0-9]{64}$/.test(frozen.originDigest) || !Array.isArray(frozen.paths))
|
|
1802
|
+
throw new Error("Interrupted QA frozen source binding is missing or invalid");
|
|
1803
|
+
const basisRow = this.db.prepare("SELECT basis_json FROM qa_review_bases WHERE run_id=? AND ticket_id=? AND digest=?").get(runId, ticketId, turn.reviewBasisDigest);
|
|
1804
|
+
if (!basisRow)
|
|
1805
|
+
throw new Error("Interrupted QA review basis is missing");
|
|
1806
|
+
const basis = parseJson(basisRow.basis_json);
|
|
1807
|
+
const { digest: basisDigest, ...basisFields } = basis;
|
|
1808
|
+
if (basisDigest !== qaDigest("review-basis-fields", basisFields) || basis.confinementDigest !== digest || basis.ticketDigest !== qaDigest("ticket", definition))
|
|
1809
|
+
throw new Error("Interrupted QA review basis is invalid");
|
|
1810
|
+
return { dispatch, turn, confinementDigest: digest };
|
|
1811
|
+
});
|
|
1812
|
+
const source = captureFrozenQaSource(this.projectDir);
|
|
1813
|
+
if (run.repository?.branch && execFileSync("git", ["branch", "--show-current"], { cwd: this.projectDir, encoding: "utf8" }).trim() !== run.repository.branch)
|
|
1814
|
+
throw new Error("Return to the build's owning branch before QA recovery");
|
|
1815
|
+
this.atomic(() => {
|
|
1816
|
+
if (captureFrozenQaSource(this.projectDir).digest !== source.digest)
|
|
1817
|
+
throw new Error("QA source changed during recovery inspection; retry");
|
|
1818
|
+
const reconciliationId = `qa-review-resume:${runId}:${randomUUID()}`;
|
|
1819
|
+
for (const { dispatch, turn, confinementDigest } of bindings)
|
|
1820
|
+
this.updateOperation(dispatch.idempotencyKey, "failed", { result: { recovery: "interrupted-read-only-qa", reconciliationId, outcomeUnknown: true, providerReplayAuthorized: false, priorResult: dispatch.result, qaTurnId: turn.operationId, reviewedSourceDigest: turn.sourceStateDigest, currentSourceDigest: source.digest, confinementDigest }, error: "Interrupted read-only QA outcome remains unknown; replaced by a fresh immutable full review, never replayed or treated as a pass" });
|
|
1821
|
+
this.appendContinuityEvent({ runId, role: "host", kind: "interrupted_qa_reconciled", payload: { reconciliationId, ticketId, dispatchIds: dispatches.map(dispatch => dispatch.idempotencyKey), currentSourceDigest: source.digest, outcomeUnknown: true, providerReplayAuthorized: false }, authoritativeStateRevision: this.continuityHead(runId, "run")?.authoritativeStateRevision ?? 0 });
|
|
1822
|
+
});
|
|
1823
|
+
return true;
|
|
1824
|
+
}
|
|
1581
1825
|
commitQaTurnIntent(intent, expectedRevision) {
|
|
1582
1826
|
return this.db.transaction(() => {
|
|
1583
1827
|
const next = this.transitionQa(intent.runId, intent.ticketId, expectedRevision, { type: "turn-intended", slot: intent.slot }, new Date(intent.intendedAt));
|
|
@@ -1748,6 +1992,21 @@ export class WorkflowDb {
|
|
|
1748
1992
|
const row = this.db.prepare("SELECT receipt_json FROM qa_remediation_receipts WHERE operation_id=?").get(operationId);
|
|
1749
1993
|
return row ? parseJson(row.receipt_json) : undefined;
|
|
1750
1994
|
}
|
|
1995
|
+
hasConfirmedQaRemediationForRecheck(runId, ticketId) {
|
|
1996
|
+
const head = this.qaTicketHead(runId, ticketId);
|
|
1997
|
+
const attempt = this.qaRemediationAttempts(runId, ticketId).at(-1);
|
|
1998
|
+
if (head.state !== "recheck-required" || !attempt || attempt.status !== "succeeded"
|
|
1999
|
+
|| attempt.generation !== head.remediationGeneration || this.unresolvedRoleDispatches(runId, "builder").length)
|
|
2000
|
+
return false;
|
|
2001
|
+
const receipt = this.builderRemediationReceipt(attempt.attemptId);
|
|
2002
|
+
const review = this.qaReviewAttempt(attempt.reviewAttemptId);
|
|
2003
|
+
const transition = this.db.prepare("SELECT to_revision,event_json FROM qa_transitions WHERE run_id=? AND ticket_id=? ORDER BY to_revision DESC LIMIT 1").get(runId, ticketId);
|
|
2004
|
+
return Boolean(receipt && review && review.status === "failed" && receipt.runId === runId && receipt.ticketId === ticketId
|
|
2005
|
+
&& receipt.operationId === attempt.attemptId && receipt.requestDigest === attempt.requestDigest
|
|
2006
|
+
&& receipt.responseDigest === attempt.responseDigest && receipt.summaryDigest === attempt.summaryDigest
|
|
2007
|
+
&& receipt.sourceStateDigest === review.sourceDigest && receipt.reportDigest === review.reportDigest && receipt.providerTurnId
|
|
2008
|
+
&& transition?.to_revision === head.revision && parseJson(transition.event_json).type === "remediation-received");
|
|
2009
|
+
}
|
|
1751
2010
|
recordBuilderRemediationReceipt(receipt) {
|
|
1752
2011
|
const text = json(receipt);
|
|
1753
2012
|
const existing = this.db.prepare("SELECT receipt_json FROM qa_remediation_receipts WHERE operation_id=?").get(receipt.operationId);
|
|
@@ -2268,17 +2527,26 @@ export class WorkflowDb {
|
|
|
2268
2527
|
*/
|
|
2269
2528
|
hasUncheckpointedRoleTurn(runId, role) {
|
|
2270
2529
|
const events = this.continuityEvents(runId);
|
|
2271
|
-
|
|
2530
|
+
const pending = new Map();
|
|
2272
2531
|
for (const event of events) {
|
|
2273
2532
|
if (event.kind === "turn_started" && event.role === "host" && event.payload.role === role) {
|
|
2274
|
-
|
|
2533
|
+
const dispatchId = event.payload.dispatchId;
|
|
2534
|
+
pending.set(event.sequence, typeof dispatchId === "string" ? dispatchId : undefined);
|
|
2535
|
+
}
|
|
2536
|
+
else if (event.role === role && event.kind === "turn_not_sent") {
|
|
2537
|
+
const dispatchId = event.payload.dispatchId;
|
|
2538
|
+
if (typeof dispatchId === "string")
|
|
2539
|
+
for (const [sequence, id] of pending) {
|
|
2540
|
+
if (id === dispatchId)
|
|
2541
|
+
pending.delete(sequence);
|
|
2542
|
+
}
|
|
2275
2543
|
}
|
|
2276
|
-
else if (
|
|
2277
|
-
&& (event.kind === "turn_completed" || event.kind === "turn_completed_after_repair" || event.kind === "handback_turn_completed" || event.kind === "fresh_successor_accepted")) {
|
|
2278
|
-
|
|
2544
|
+
else if (event.role === role
|
|
2545
|
+
&& (event.kind === "turn_completed" || event.kind === "turn_completed_after_repair" || event.kind === "handback_turn_completed" || event.kind === "fresh_successor_accepted" || event.kind === "handoff_successor_accepted")) {
|
|
2546
|
+
pending.clear();
|
|
2279
2547
|
}
|
|
2280
2548
|
}
|
|
2281
|
-
return
|
|
2549
|
+
return pending.size > 0;
|
|
2282
2550
|
}
|
|
2283
2551
|
beginPublication(runId, intent, previousDigests, now = new Date()) {
|
|
2284
2552
|
const transactionId = randomUUID();
|
package/package.json
CHANGED