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.
Files changed (40) hide show
  1. package/README.md +2 -1
  2. package/dist/adapters/claude.d.ts +15 -0
  3. package/dist/adapters/claude.js +149 -28
  4. package/dist/adapters/codex.d.ts +9 -0
  5. package/dist/adapters/codex.js +77 -8
  6. package/dist/adapters/fastMode.d.ts +4 -0
  7. package/dist/adapters/fastMode.js +9 -0
  8. package/dist/adapters/types.d.ts +2 -0
  9. package/dist/agentRun.js +1 -0
  10. package/dist/branch/runner.js +46 -44
  11. package/dist/buildAssignment.d.ts +15 -0
  12. package/dist/buildAssignment.js +142 -5
  13. package/dist/buildRuns.d.ts +6 -0
  14. package/dist/buildRuns.js +73 -7
  15. package/dist/cli/start.d.ts +5 -0
  16. package/dist/cli/start.js +123 -33
  17. package/dist/continuity.js +11 -10
  18. package/dist/foreman.d.ts +8 -0
  19. package/dist/foreman.js +69 -10
  20. package/dist/handoffProtocol.d.ts +4 -0
  21. package/dist/handoffProtocol.js +19 -0
  22. package/dist/handoffs.d.ts +1 -1
  23. package/dist/handoffs.js +23 -4
  24. package/dist/permissions/policy.js +18 -11
  25. package/dist/permissions/shellForms.d.ts +3 -0
  26. package/dist/permissions/shellForms.js +203 -0
  27. package/dist/qaBuilderActivation.d.ts +19 -0
  28. package/dist/qaBuilderActivation.js +112 -0
  29. package/dist/qaFailureDelivery.js +1 -1
  30. package/dist/qaRecovery.js +2 -1
  31. package/dist/qaReview.d.ts +4 -0
  32. package/dist/qaReview.js +37 -7
  33. package/dist/sessionLifecycle.js +1 -1
  34. package/dist/supervisedStart.js +23 -3
  35. package/dist/supervisor.js +33 -0
  36. package/dist/tickets/groupReset.js +100 -96
  37. package/dist/tickets/reset.js +80 -69
  38. package/dist/workflowDb.d.ts +16 -1
  39. package/dist/workflowDb.js +276 -8
  40. package/package.json +1 -1
@@ -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
- const workflow = new WorkflowDb(projectDir);
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 stamp = now.toISOString();
86
- db.transaction(() => {
87
- for (const row of preview.tickets) {
88
- db.upsertState(row.id, pristineTicketState(row.id, stamp, actor), stamp);
89
- db.deleteRecentCompleted(row.id);
90
- insertedEventIds.push(db.insertEvent({
91
- timestamp: stamp,
92
- actor,
93
- ticket_id: row.id,
94
- event_type: "ticket_reset",
95
- old_status: row.status,
96
- new_status: "planned",
97
- summary: `Reset ${row.id} to pristine active state`,
98
- validation: null,
99
- evidence: null,
100
- payload_json: JSON.stringify({ reset_id: resetId, scope: preview.scope, cleared: row.cleared }),
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
- mkdirSync(dirname(stagePath), { recursive: true });
105
- writeFileSync(stagePath, renderProgressDoc({ config, projectDir, ticketDefs: tickets, states: db.getAllStates(), db, now: stamp }), "utf8");
106
- workflow.updatePublication(publication.transactionId, "staged", now);
107
- workflow.updatePublication(publication.transactionId, "tracker_committed", now);
108
- hooks.beforePublish?.();
109
- writeProgressDoc(paths.progressDoc, readFileSync(stagePath, "utf8"), true);
110
- workflow.updatePublication(publication.transactionId, "published", now);
111
- workflow.updatePublication(publication.transactionId, "committed", now);
112
- workflow.transition(run.runId, { status: "completed", checkpoint: "ticket-reset-committed", remainingWork: {}, state: { operation: "ticket-reset", resetId, tickets: ids } }, now);
113
- rmSync(dirname(stagePath), { recursive: true, force: true });
114
- return { resetId, tickets: ids };
115
- }
116
- catch (error) {
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
- db.deleteState(id);
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
- workflow.close();
139
- db.close();
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. */
@@ -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: {
@@ -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 { taggedProcesses, classifyProcess, processStartIdentity } from "./processIdentity.js";
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
- let startSequence = 0;
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
- startSequence = event.sequence;
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 (startSequence && event.sequence > startSequence && event.role === role
2277
- && (event.kind === "turn_completed" || event.kind === "turn_completed_after_repair" || event.kind === "handback_turn_completed" || event.kind === "fresh_successor_accepted")) {
2278
- startSequence = 0;
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 startSequence > 0;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-foreman",
3
- "version": "1.7.25",
3
+ "version": "1.7.26",
4
4
  "description": "An orchestrator that keeps Codex CLI / Claude Code builders moving through their step list unattended. Part of Rafi.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",