ai-foreman 1.7.19 → 1.7.21

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 (205) hide show
  1. package/README.md +29 -5
  2. package/dist/activity.d.ts +3 -0
  3. package/dist/activity.js +18 -6
  4. package/dist/adapters/claude.d.ts +16 -0
  5. package/dist/adapters/claude.js +28 -3
  6. package/dist/adapters/codex.d.ts +20 -0
  7. package/dist/adapters/codex.js +99 -5
  8. package/dist/adapters/recovering.d.ts +9 -0
  9. package/dist/adapters/recovering.js +10 -0
  10. package/dist/adapters/types.d.ts +15 -0
  11. package/dist/agentRun.d.ts +7 -0
  12. package/dist/agentRun.js +24 -13
  13. package/dist/branch/currentGuard.d.ts +1 -0
  14. package/dist/branch/currentGuard.js +1 -0
  15. package/dist/branch/finalization.js +6 -1
  16. package/dist/branch/git.d.ts +1 -0
  17. package/dist/branch/git.js +15 -1
  18. package/dist/branch/runner.d.ts +2 -0
  19. package/dist/branch/runner.js +92 -5
  20. package/dist/buildAdmission.d.ts +61 -0
  21. package/dist/buildAdmission.js +286 -0
  22. package/dist/buildApproval.d.ts +2 -0
  23. package/dist/buildApproval.js +17 -0
  24. package/dist/buildAssignment.d.ts +33 -0
  25. package/dist/buildAssignment.js +215 -0
  26. package/dist/buildInterventions.d.ts +90 -0
  27. package/dist/buildInterventions.js +334 -0
  28. package/dist/buildLaunchGate.d.ts +1 -0
  29. package/dist/buildLaunchGate.js +36 -0
  30. package/dist/buildOwnershipReconciliation.d.ts +19 -0
  31. package/dist/buildOwnershipReconciliation.js +41 -0
  32. package/dist/buildOwnershipRepair.d.ts +33 -0
  33. package/dist/buildOwnershipRepair.js +111 -0
  34. package/dist/buildRuns.d.ts +24 -1
  35. package/dist/buildRuns.js +216 -70
  36. package/dist/buildSyntheticWork.d.ts +2 -0
  37. package/dist/buildSyntheticWork.js +30 -0
  38. package/dist/buildWorkAdmission.d.ts +17 -0
  39. package/dist/buildWorkAdmission.js +57 -0
  40. package/dist/buildWorkAuthorization.d.ts +2 -0
  41. package/dist/buildWorkAuthorization.js +23 -0
  42. package/dist/buildWorkContext.d.ts +3 -0
  43. package/dist/buildWorkContext.js +6 -0
  44. package/dist/buildWorkMigration.d.ts +9 -0
  45. package/dist/buildWorkMigration.js +206 -0
  46. package/dist/buildWorkReconciliation.d.ts +12 -0
  47. package/dist/buildWorkReconciliation.js +70 -0
  48. package/dist/builderGuidanceFollowup.d.ts +15 -0
  49. package/dist/builderGuidanceFollowup.js +34 -0
  50. package/dist/cli/doctor.js +3 -2
  51. package/dist/cli/graph.d.ts +2 -0
  52. package/dist/cli/graph.js +75 -0
  53. package/dist/cli/manager.d.ts +8 -0
  54. package/dist/cli/manager.js +234 -12
  55. package/dist/cli/recovery.js +3 -2
  56. package/dist/cli/runtimeAuthPrompt.d.ts +5 -0
  57. package/dist/cli/runtimeAuthPrompt.js +41 -7
  58. package/dist/cli/start.d.ts +8 -0
  59. package/dist/cli/start.js +2091 -1849
  60. package/dist/cli/status.js +20 -5
  61. package/dist/cli/tickets.js +9 -7
  62. package/dist/continuity.d.ts +16 -1
  63. package/dist/continuity.js +68 -12
  64. package/dist/foreman.d.ts +12 -4
  65. package/dist/foreman.js +397 -60
  66. package/dist/graph/boundary.d.ts +15 -0
  67. package/dist/graph/boundary.js +50 -0
  68. package/dist/graph/bridge.d.ts +9 -0
  69. package/dist/graph/bridge.js +146 -0
  70. package/dist/graph/bridge.py +205 -0
  71. package/dist/graph/config.d.ts +11 -0
  72. package/dist/graph/config.js +40 -0
  73. package/dist/graph/corpus.d.ts +16 -0
  74. package/dist/graph/corpus.js +188 -0
  75. package/dist/graph/derived.d.ts +17 -0
  76. package/dist/graph/derived.js +73 -0
  77. package/dist/graph/host.d.ts +4 -0
  78. package/dist/graph/host.js +160 -0
  79. package/dist/graph/install.d.ts +3 -0
  80. package/dist/graph/install.js +50 -0
  81. package/dist/graph/lifecycle.d.ts +11 -0
  82. package/dist/graph/lifecycle.js +124 -0
  83. package/dist/graph/maintenance.d.ts +43 -0
  84. package/dist/graph/maintenance.js +301 -0
  85. package/dist/graph/manager.d.ts +9 -0
  86. package/dist/graph/manager.js +92 -0
  87. package/dist/graph/preparation.d.ts +41 -0
  88. package/dist/graph/preparation.js +43 -0
  89. package/dist/graph/read.d.ts +27 -0
  90. package/dist/graph/read.js +152 -0
  91. package/dist/graph/semantic.d.ts +17 -0
  92. package/dist/graph/semantic.js +27 -0
  93. package/dist/graph/session.d.ts +8 -0
  94. package/dist/graph/session.js +92 -0
  95. package/dist/graph/sources.d.ts +4 -0
  96. package/dist/graph/sources.js +11 -0
  97. package/dist/graph/storage.d.ts +38 -0
  98. package/dist/graph/storage.js +159 -0
  99. package/dist/graph/turn.d.ts +17 -0
  100. package/dist/graph/turn.js +360 -0
  101. package/dist/graph/util.d.ts +5 -0
  102. package/dist/graph/util.js +51 -0
  103. package/dist/handoffs.d.ts +1 -0
  104. package/dist/handoffs.js +5 -1
  105. package/dist/humanDecision.d.ts +21 -2
  106. package/dist/humanDecision.js +169 -10
  107. package/dist/index.js +4 -1
  108. package/dist/installOwnership.d.ts +46 -0
  109. package/dist/installOwnership.js +440 -0
  110. package/dist/linuxProbeInventory.d.ts +12 -0
  111. package/dist/linuxProbeInventory.js +48 -0
  112. package/dist/log.d.ts +1 -1
  113. package/dist/log.js +7 -1
  114. package/dist/managerEvidence.d.ts +25 -0
  115. package/dist/managerEvidence.js +326 -0
  116. package/dist/managerEvidenceArtifacts.d.ts +38 -0
  117. package/dist/managerEvidenceArtifacts.js +118 -0
  118. package/dist/managerPacket.d.ts +2 -0
  119. package/dist/managerPacket.js +23 -0
  120. package/dist/markers.d.ts +1 -1
  121. package/dist/markers.js +2 -2
  122. package/dist/observability.js +1 -1
  123. package/dist/processIdentity.d.ts +21 -1
  124. package/dist/processIdentity.js +87 -5
  125. package/dist/providerPhase.d.ts +13 -0
  126. package/dist/providerPhase.js +26 -0
  127. package/dist/qaAuthorizedDisposition.d.ts +24 -0
  128. package/dist/qaAuthorizedDisposition.js +26 -0
  129. package/dist/qaBuildGate.d.ts +4 -0
  130. package/dist/qaBuildGate.js +216 -0
  131. package/dist/qaBuilderCoverage.d.ts +5 -0
  132. package/dist/qaBuilderCoverage.js +78 -0
  133. package/dist/qaContractCoverage.d.ts +10 -0
  134. package/dist/qaContractCoverage.js +55 -0
  135. package/dist/qaContractDelivery.d.ts +23 -0
  136. package/dist/qaContractDelivery.js +145 -0
  137. package/dist/qaContractFreshness.d.ts +15 -0
  138. package/dist/qaContractFreshness.js +36 -0
  139. package/dist/qaDeliveryJournal.d.ts +10 -0
  140. package/dist/qaDeliveryJournal.js +16 -1
  141. package/dist/qaEffectiveConfig.d.ts +20 -0
  142. package/dist/qaEffectiveConfig.js +40 -0
  143. package/dist/qaEvidenceReader.d.ts +14 -0
  144. package/dist/qaEvidenceReader.js +176 -0
  145. package/dist/qaFailureDelivery.d.ts +2 -0
  146. package/dist/qaFailureDelivery.js +107 -10
  147. package/dist/qaHandbackMigration.d.ts +1 -1
  148. package/dist/qaHandbackMigration.js +3 -3
  149. package/dist/qaPreparation.d.ts +26 -0
  150. package/dist/qaPreparation.js +150 -0
  151. package/dist/qaPreparationInputs.d.ts +8 -0
  152. package/dist/qaPreparationInputs.js +47 -0
  153. package/dist/qaPreparationMetrics.d.ts +145 -0
  154. package/dist/qaPreparationMetrics.js +120 -0
  155. package/dist/qaPreparationPolicy.d.ts +5 -0
  156. package/dist/qaPreparationPolicy.js +51 -0
  157. package/dist/qaPreparationStore.d.ts +157 -0
  158. package/dist/qaPreparationStore.js +412 -0
  159. package/dist/qaProtocolV2.d.ts +28 -0
  160. package/dist/qaProtocolV2.js +12 -0
  161. package/dist/qaReview.d.ts +6 -0
  162. package/dist/qaReview.js +293 -41
  163. package/dist/qaTimeline.d.ts +18 -0
  164. package/dist/qaTimeline.js +123 -0
  165. package/dist/qaVerificationContract.d.ts +17 -0
  166. package/dist/qaVerificationContract.js +236 -0
  167. package/dist/readinessCleanup.d.ts +19 -0
  168. package/dist/readinessCleanup.js +122 -0
  169. package/dist/readinessGate.d.ts +1 -0
  170. package/dist/readinessGate.js +82 -0
  171. package/dist/recoveryGuidance.d.ts +17 -0
  172. package/dist/recoveryGuidance.js +38 -0
  173. package/dist/runtimeAuth.d.ts +1 -0
  174. package/dist/runtimeAuth.js +5 -1
  175. package/dist/runtimeReadiness.d.ts +13 -0
  176. package/dist/runtimeReadiness.js +206 -10
  177. package/dist/sessionIdentity.d.ts +2 -0
  178. package/dist/sessionIdentity.js +14 -0
  179. package/dist/sessionLifecycle.js +3 -3
  180. package/dist/sources/sourceRegistry.js +7 -0
  181. package/dist/stateTransfer.js +85 -8
  182. package/dist/statusReporter.d.ts +1 -0
  183. package/dist/statusReporter.js +1 -0
  184. package/dist/supervisedStart.d.ts +6 -0
  185. package/dist/supervisedStart.js +178 -62
  186. package/dist/supervisor.d.ts +2 -0
  187. package/dist/supervisor.js +26 -2
  188. package/dist/terminalStyle.d.ts +21 -0
  189. package/dist/terminalStyle.js +50 -0
  190. package/dist/ticketPopulation.d.ts +1 -0
  191. package/dist/ticketPopulation.js +5 -1
  192. package/dist/tickets/commands.js +2 -2
  193. package/dist/tickets/queue.js +1 -1
  194. package/dist/tickets/ticketSchema.d.ts +198 -0
  195. package/dist/tickets/ticketSchema.js +2 -0
  196. package/dist/windowsProbeJob.d.ts +12 -0
  197. package/dist/windowsProbeJob.js +144 -0
  198. package/dist/workflowDb.d.ts +121 -6
  199. package/dist/workflowDb.js +874 -30
  200. package/dist/workflowReader.d.ts +116 -2
  201. package/dist/workflowReader.js +131 -4
  202. package/package.json +89 -8
  203. package/scripts/copy-graph-assets.mjs +3 -0
  204. package/scripts/graph-benchmark.mjs +20 -0
  205. package/scripts/graph-evaluation.mjs +85 -0
@@ -1,11 +1,25 @@
1
+ import { registerQaGraphJournalWriter, ensureQaGraphJournalGuards } from "./qaDeliveryJournal.js";
2
+ import { canonicalJson } from "./qaProtocolV2.js";
3
+ import { resolveEffectiveQaConfiguration } from "./qaEffectiveConfig.js";
4
+ import { registerGraphDerived, graphDerivedAllowed, currentGraphDerivedAccess } from "./graph/derived.js";
5
+ import { parseContractCoverage, validateContractCoverage } from "./qaContractCoverage.js";
6
+ import { QaPreparationStore, migrateQaPreparation, migrateQaPreparationGuards } from "./qaPreparationStore.js";
7
+ import { GraphStore, migrateGraphStore } from "./graph/storage.js";
8
+ import { reconcileWork } from "./buildWorkReconciliation.js";
9
+ import { migrateBuildInterventions, hasQueuedBuilderGuidance, reconcileGuidance, decisionWorkId, queuedControls, completeControl, verifyGuidance, builderVerificationContext, verifyBuilderGuidance, assertFinalizationControls, reserveGuidance, finishGuidance, instruction } from "./buildInterventions.js";
10
+ import { migrateBuildWork } from "./buildWorkMigration.js";
11
+ import { admitWork, admittedWork, assertAdmittedWork } from "./buildWorkAdmission.js";
12
+ import { cleanupReadiness, inspectReadiness, readinessMetadata } from "./readinessCleanup.js";
13
+ import { originalBuildLease, rememberOriginalLease, forgetOriginalLease, registerLaunchChild, acknowledgeLaunchChild, reconcileLaunch, checkBuildOwnershipSchema, canonicalProject, launchDigest, localBuildAuthority, rememberBuildAuthority, forgetBuildAuthority, migrateBuildAdmission, acquireAdmission, readAdmission, assertAdmission, releaseAdmission, reserveLaunch, setLaunchState, claimLaunch, readLaunch } from "./buildAdmission.js";
1
14
  import { createHash, randomUUID } from "node:crypto";
2
15
  import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync } from "node:fs";
3
16
  import { hostname } from "node:os";
4
17
  import { dirname, join, resolve } from "node:path";
5
18
  import Database from "better-sqlite3";
19
+ import { validateQaFailureReport } from "rafi-spec";
6
20
  import { migrateQaHandback, registerHandbackWriter, reportOccurrenceId } from "./qaHandbackMigration.js";
7
21
  import { providerSessionKey } from "./sessionIdentity.js";
8
- import { processStartIdentity } from "./processIdentity.js";
22
+ import { taggedProcesses, classifyProcess, processStartIdentity } from "./processIdentity.js";
9
23
  import { initialQaReducerState, qaDigest, reduceQaState, } from "./qaProtocolV2.js";
10
24
  export const WORKFLOW_DB_FILE = ".rafi/recovery.sqlite3";
11
25
  /** Read the current project lease without creating or migrating recovery state. */
@@ -34,6 +48,7 @@ export function heartbeatCurrentWorkflowLease(projectDir, lease, now = new Date(
34
48
  if (!existsSync(path))
35
49
  throw new Error("workflow recovery database not found");
36
50
  const db = new Database(path, { fileMustExist: true });
51
+ db.function("rafi_protocol_v3", () => 1);
37
52
  registerHandbackWriter(db);
38
53
  try {
39
54
  const at = now.toISOString();
@@ -50,30 +65,617 @@ export function heartbeatCurrentWorkflowLease(projectDir, lease, now = new Date(
50
65
  /** Authoritative persistence for every resumable workflow in a project. */
51
66
  export class WorkflowDb {
52
67
  projectDir;
68
+ readinessAccess;
53
69
  path;
54
70
  db;
55
- constructor(projectDir, path = join(resolve(projectDir), WORKFLOW_DB_FILE)) {
71
+ writerAuthority;
72
+ writerLease;
73
+ coordinatorTransition = 0;
74
+ lineageTransition = 0;
75
+ workAuthority = 0;
76
+ constructor(projectDir, path = join(resolve(projectDir), WORKFLOW_DB_FILE), readinessAccess) {
56
77
  this.projectDir = projectDir;
78
+ this.readinessAccess = readinessAccess;
57
79
  this.path = path;
58
- mkdirSync(dirname(path), { recursive: true });
59
- ensureRecoveryGitignore(resolve(projectDir));
60
- this.db = new Database(path);
80
+ if (existsSync(path)) {
81
+ const preview = new Database(path, { readonly: true, fileMustExist: true });
82
+ try {
83
+ checkBuildOwnershipSchema(preview);
84
+ }
85
+ finally {
86
+ preview.close();
87
+ }
88
+ }
89
+ if (!readinessAccess) {
90
+ mkdirSync(dirname(path), { recursive: true });
91
+ ensureRecoveryGitignore(resolve(projectDir));
92
+ }
93
+ this.db = new Database(path, { fileMustExist: Boolean(readinessAccess) });
94
+ this.db.function("rafi_protocol_v3", () => 1);
95
+ this.writerAuthority = localBuildAuthority(projectDir);
96
+ this.writerLease = originalBuildLease(projectDir);
97
+ this.db.function("rafi_build_lease_owner", () => this.writerLease?.owner ?? "");
98
+ this.db.function("rafi_build_lease_generation", () => this.writerLease?.generation ?? -1);
99
+ this.db.function("rafi_build_writer_token", () => this.writerAuthority?.token ?? "");
100
+ this.db.function("rafi_build_writer_run", () => this.writerAuthority?.runId ?? "");
101
+ this.db.function("rafi_work_authority", () => this.workAuthority);
61
102
  registerHandbackWriter(this.db);
62
- this.db.pragma("journal_mode = WAL");
103
+ registerQaGraphJournalWriter(this.db);
104
+ if (!readinessAccess)
105
+ this.db.pragma("journal_mode = WAL");
63
106
  this.db.pragma("synchronous = FULL");
64
107
  this.db.pragma("foreign_keys = ON");
65
108
  try {
66
- this.migrate();
67
- this.importLegacyOnce();
109
+ if (readinessAccess)
110
+ this.restrictReadinessConnection(readinessAccess);
111
+ else {
112
+ this.migrate();
113
+ migrateQaPreparation(this.db);
114
+ migrateBuildAdmission(this.db);
115
+ this.importLegacyOnce();
116
+ migrateBuildWork(this.db, projectDir);
117
+ migrateBuildInterventions(this.db);
118
+ migrateQaPreparationGuards(this.db);
119
+ }
68
120
  }
69
121
  catch (error) {
70
122
  this.db.close();
71
123
  throw error;
72
124
  }
73
125
  }
126
+ /** Connection-local SQL fences: helper/reconciler cannot gain general writer rights. */
127
+ restrictReadinessConnection(access) {
128
+ const value = "probeId" in access ? access.probeId : access.runId;
129
+ const literal = this.db.prepare("SELECT quote(?) AS literal").get(value).literal;
130
+ for (const { name } of this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").all()) {
131
+ if (!/^[a-z_]+$/.test(name))
132
+ throw new Error("Unexpected readiness storage table");
133
+ for (const action of ["INSERT", "UPDATE", "DELETE"]) {
134
+ const allowed = name === "build_owned_processes" && action === "UPDATE";
135
+ const condition = allowed ? `WHEN OLD.${"probeId" in access ? "id" : "run_id"}<>${literal} OR NEW.id<>OLD.id OR NEW.run_id<>OLD.run_id OR NEW.owner<>OLD.owner OR NEW.host<>OLD.host` : "";
136
+ this.db.exec(`CREATE TEMP TRIGGER readiness_scope_${name}_${action} BEFORE ${action} ON main.${name} ${condition} BEGIN SELECT RAISE(ABORT,'Restricted readiness connection cannot mutate this record'); END`);
137
+ }
138
+ }
139
+ }
74
140
  close() { this.db.close(); }
141
+ qaPreparationStore() { return new QaPreparationStore(this.db, runId => this.assertInstructionOwner(runId), this.projectDir); }
142
+ graphStore() { migrateGraphStore(this.db); return new GraphStore(this.db); }
143
+ admitWork(input) {
144
+ this.assertInstructionOwner(input.runId);
145
+ this.workAuthority++;
146
+ try {
147
+ const result = admitWork(this.db, input);
148
+ this.qaPreparationStore().metric(input.runId, result.workId, result.requirementsDigest, `metric:approved:${input.runId}:${result.workId}:${result.requirementsDigest}`, "approved");
149
+ return result;
150
+ }
151
+ finally {
152
+ this.workAuthority--;
153
+ }
154
+ }
155
+ assertInstructionOwner(runId) {
156
+ if (this.writerAuthority) {
157
+ assertAdmission(this.db, this.writerAuthority);
158
+ if (this.writerAuthority.runId !== runId && !this.db.prepare("SELECT 1 FROM build_child_runs WHERE child=? AND parent=?").get(runId, this.writerAuthority.runId))
159
+ throw new Error("Instruction execution authority belongs to another run");
160
+ return;
161
+ }
162
+ if (this.buildAdmission())
163
+ throw new Error("Instruction consumption requires the original build owner");
164
+ if (this.db.prepare("SELECT 1 FROM build_runtime_runs WHERE run_id=?").get(runId)) {
165
+ const held = this.currentLease();
166
+ if (!held || !this.writerLease || held.owner !== this.writerLease.owner || held.generation !== this.writerLease.generation || held.runId !== runId)
167
+ throw new Error("Instruction execution requires the original workflow lease");
168
+ }
169
+ }
170
+ reserveGuidance(runId, workId, recipient, operationId, sourceDigest, text, reserve = true) {
171
+ this.assertInstructionOwner(runId);
172
+ return reserveGuidance(this.db, runId, workId, recipient, operationId, sourceDigest, text, reserve);
173
+ }
174
+ reconcileInstructionDeliveries(runId, workId) { this.assertInstructionOwner(runId); this.atomic(() => reconcileGuidance(this.db, runId, workId)); }
175
+ decisionWorkId(decision) { return decisionWorkId(this.db, decision); }
176
+ finishGuidance(ids, recipient, result) { for (const id of ids)
177
+ this.assertInstructionOwner(instruction(this.db, id).request.runId); finishGuidance(this.db, ids, recipient, result); }
178
+ builderVerificationContext(runId, workId, sourceDigest) { return builderVerificationContext(this.db, runId, workId, sourceDigest); }
179
+ verifyBuilderGuidance(ids, certificate, response, receiptDigest) { for (const id of ids)
180
+ this.assertInstructionOwner(instruction(this.db, id).request.runId); return this.atomic(() => verifyBuilderGuidance(this.db, ids, certificate, response, receiptDigest)); }
181
+ verifyGuidance(ids, certificate, response) { for (const id of ids)
182
+ this.assertInstructionOwner(instruction(this.db, id).request.runId); return this.atomic(() => verifyGuidance(this.db, ids, certificate, response)); }
183
+ hasQueuedBuilderGuidance(runId, workId) { return hasQueuedBuilderGuidance(this.db, runId, workId); }
184
+ instruction(id) { return instruction(this.db, id); }
185
+ assertFinalizationControls(runId, workId) { assertFinalizationControls(this.db, runId, workId); }
186
+ consumeInstructionControls(runId, workId, kind = "questions") {
187
+ return this.atomic(() => {
188
+ this.assertInstructionOwner(runId);
189
+ const admission = this.assertAdmittedWork(runId, workId);
190
+ const latestReview = this.qaReviewAttempts(runId, workId).at(-1);
191
+ let authorization = kind === "attempts" && latestReview && ["failed", "passed"].includes(latestReview.status) ? this.db.prepare("SELECT a.authorization_id FROM qa_remediation_authorizations a JOIN build_instruction_deliveries d ON json_extract(d.record_json,'$.receipt.authorizationId')=a.authorization_id JOIN build_instructions i USING(instruction_id) WHERE a.run_id=? AND a.ticket_id=? AND a.review_attempt_id=? AND a.consumed_by IS NULL AND d.state='applied' AND json_extract(i.record_json,'$.requirementsDigest')=? AND json_extract(i.record_json,'$.basis.assignmentId')=? AND json_extract(i.record_json,'$.basis.scopeRevision')=? ORDER BY i.sequence LIMIT 1").get(runId, workId, latestReview.attemptId, admission.requirementsDigest, admission.assignmentId, admission.scopeRevision)?.authorization_id : undefined;
192
+ if (authorization) {
193
+ const head = this.qaTicketHead(runId, workId);
194
+ const eligible = latestReview?.status === "passed"
195
+ ? head.state === "passed" && this.hasQueuedBuilderGuidance(runId, workId)
196
+ : head.state === "review-failed" && this.unresolvedQaReports(runId, workId).some(report => report.reviewNumber === latestReview.reviewNumber && report.reportDigest === latestReview.reportDigest);
197
+ if (!eligible)
198
+ authorization = undefined;
199
+ }
200
+ for (const record of queuedControls(this.db, runId, workId).filter(record => kind === "questions" ? record.request.action === "answer_question" : record.request.action === "request_attempt")) {
201
+ const priorAuthorization = authorization;
202
+ try {
203
+ this.atomic(() => {
204
+ if (record.requirementsDigest !== admission.requirementsDigest)
205
+ throw new Error("Instruction requirements are stale");
206
+ if (record.request.action === "answer_question") {
207
+ const decision = this.humanDecision(record.request.decisionId);
208
+ if (!decision || decision.runId !== runId || this.decisionWorkId(decision) !== workId || createHash("sha256").update(json(decision)).digest("hex") !== record.request.decisionRevision)
209
+ throw new Error("Pending question identity or revision changed");
210
+ const choice = decision.choices.find(choice => choice.id === record.request.text || choice.label === record.request.text) ?? decision.choices.find(choice => choice.id === "custom" || choice.id === "answer");
211
+ if (!choice)
212
+ throw new Error("Answer must name an offered choice or use a question accepting custom text");
213
+ const answer = this.answerHumanDecision(runId, decision.decisionId, choice.id, new Date(), choice.id === "custom" || choice.id === "answer" ? record.request.text : undefined);
214
+ completeControl(this.db, record.instructionId, { decisionId: answer.decisionId, status: answer.status });
215
+ }
216
+ else {
217
+ const latest = this.qaReviewAttempts(runId, workId).at(-1);
218
+ if (!latest || !["failed", "passed"].includes(latest.status) || latest.attemptId !== record.basis.reviewAttemptId || authorization)
219
+ throw new Error("One extra attempt requires the current eligible review; another authorization is already available");
220
+ if (record.basis.assignmentId !== admission.assignmentId || record.basis.scopeRevision !== admission.scopeRevision)
221
+ throw new Error("Attempt authorization scope changed");
222
+ const head = this.qaTicketHead(runId, workId);
223
+ if (latest.status === "passed") {
224
+ if (head.state !== "passed" || !this.hasQueuedBuilderGuidance(runId, workId))
225
+ throw new Error("Follow-up authorization requires waiting Builder guidance on the current unfinalized pass");
226
+ authorization = this.authorizeQaRemediation(runId, workId, latest.attemptId, record.request.text);
227
+ completeControl(this.db, record.instructionId, { authorizationId: authorization, reviewAttemptId: latest.attemptId, consumed: false, kind: "builder-guidance-followup" });
228
+ return;
229
+ }
230
+ const report = this.unresolvedQaReports(runId, workId).find(report => report.reviewNumber === latest.reviewNumber && report.reportDigest === latest.reportDigest && report.sourceStateDigest === latest.sourceDigest && report.reviewBasisDigest === head.reviewBasisDigest);
231
+ if (!report || !validateQaFailureReport(report.report).valid || head.reviewNumber !== latest.reviewNumber || head.sourceStateDigest !== latest.sourceDigest || !["review-failed", "operator-menu"].includes(head.state) || this.qaRemediationAttempts(runId, workId).some(attempt => attempt.reviewAttemptId === latest.attemptId))
232
+ throw new Error("Extra attempt requires an unconsumed validated failed-review basis; reconcile uncertain execution separately");
233
+ const decisions = this.db.prepare("SELECT decision_id,status,decision_json FROM human_decisions WHERE run_id=? AND decision_key LIKE ?").all(runId, `${runId}:qa-nonconvergence:%`).filter(row => this.decisionWorkId(parseJson(row.decision_json)) === workId && parseJson(row.decision_json).createdAt >= latest.createdAt);
234
+ if (decisions.some(row => row.status === "answered"))
235
+ throw new Error("The nonconvergence decision was already answered; resume that decision before requesting another attempt");
236
+ authorization = this.authorizeQaRemediation(runId, workId, latest.attemptId, record.request.text);
237
+ for (const row of decisions.filter(row => row.status === "pending")) {
238
+ const decision = parseJson(row.decision_json);
239
+ const at = new Date().toISOString();
240
+ this.db.prepare("UPDATE human_decisions SET status='cancelled',decision_json=?,updated_at=? WHERE decision_id=? AND status='pending'").run(json({ ...decision, status: "cancelled", cancellationReason: `Superseded by scoped attempt instruction ${record.instructionId}`, cancelledAt: at }), at, row.decision_id);
241
+ this.insertEvent(runId, "human_decision_cancelled", "attempt-authorized", { decisionId: row.decision_id, instructionId: record.instructionId }, at);
242
+ }
243
+ if (head.state === "operator-menu")
244
+ this.transitionQa(runId, workId, head.revision, { type: "failed-review-restored", reviewNumber: latest.reviewNumber, sourceStateDigest: latest.sourceDigest, reportDigest: report.reportDigest });
245
+ completeControl(this.db, record.instructionId, { authorizationId: authorization, reviewAttemptId: latest.attemptId, consumed: false });
246
+ }
247
+ });
248
+ }
249
+ catch (error) {
250
+ authorization = priorAuthorization;
251
+ completeControl(this.db, record.instructionId, undefined, String(error));
252
+ }
253
+ }
254
+ return authorization;
255
+ });
256
+ }
257
+ reconcileWork(request, input) {
258
+ this.assertInstructionOwner(request.runId);
259
+ if (this.writerAuthority)
260
+ assertAdmission(this.db, this.writerAuthority);
261
+ this.workAuthority++;
262
+ try {
263
+ return this.atomic(() => reconcileWork(this.db, request, input));
264
+ }
265
+ finally {
266
+ this.workAuthority--;
267
+ }
268
+ }
269
+ reconciliation(id) {
270
+ const row = this.db.prepare("SELECT record_json FROM build_reconciliations WHERE reconciliation_id=?").get(id);
271
+ return row ? JSON.parse(row.record_json) : undefined;
272
+ }
273
+ workDefinitions(runId) {
274
+ return this.db.prepare("SELECT work_id,kind,definition_json FROM build_work_scope WHERE run_id=? AND state='admitted' ORDER BY rowid").all(runId).map(row => ({ workId: row.work_id, kind: row.kind, definition: JSON.parse(row.definition_json) }));
275
+ }
276
+ recordWorkAssignment(runId, workId, operationId, record) {
277
+ this.assertInstructionOwner(runId);
278
+ const admission = this.assertAdmittedWork(runId, workId);
279
+ this.qaPreparationStore().metric(runId, workId, admission.requirementsDigest, `metric:started:${runId}:${workId}:${admission.requirementsDigest}`, "implementation-started");
280
+ this.workAuthority++;
281
+ try {
282
+ this.db.prepare("INSERT INTO build_assignments VALUES(?,?,?,?,?)").run(operationId, runId, workId, operationId, json(record));
283
+ }
284
+ finally {
285
+ this.workAuthority--;
286
+ }
287
+ }
288
+ admittedWork(runId, workId) { return admittedWork(this.db, runId, workId); }
289
+ assertAdmittedWork(runId, workId) { return assertAdmittedWork(this.db, runId, workId); }
75
290
  /** Atomic local changes only; never await provider or filesystem work here. */
76
291
  atomic(work) { return this.db.transaction(work).immediate(); }
292
+ buildAdmission() { return readAdmission(this.db); }
293
+ assertNoLegacyBuildOwner() {
294
+ for (const row of this.db.prepare("SELECT run_id,state_json FROM workflow_runs WHERE kind='build' AND legacy=1").all()) {
295
+ const state = parseJson(row.state_json);
296
+ if (!state.lease || ["completed", "cancelled", "superseded"].includes(state.status ?? ""))
297
+ continue;
298
+ const owner = classifyProcess(state.lease.pid, state.lease.processStart, state.lease.hostname ?? "unknown-host");
299
+ if (owner.state !== "dead")
300
+ throw new Error(`Legacy build ${row.run_id} ownership is ${owner.state}: ${owner.reason}; reconcile before starting another writer`);
301
+ }
302
+ }
303
+ assertExecutionProtocol() {
304
+ if (this.db.prepare("SELECT version FROM build_ownership_schema").get().version !== 3)
305
+ throw new Error("Ownership migration is pending legacy execution cleanup; use rafi resume before starting work");
306
+ }
307
+ acquireBuildAdmission(runId, phase) {
308
+ return this.atomic(() => {
309
+ this.assertNoLegacyBuildOwner();
310
+ const lease = this.currentLease();
311
+ if (lease && classifyProcess(lease.pid, lease.processStart, lease.host).state !== "dead")
312
+ throw new Error(`project workflow lease is held by ${lease.owner} for run ${lease.runId}`);
313
+ for (const owner of this.runningSupervisors()) {
314
+ if (owner.state.pid && classifyProcess(owner.state.pid, owner.state.processStart).state !== "dead")
315
+ throw new Error(`supervisor already active for project (run ${owner.runId})`);
316
+ }
317
+ const uncertain = this.db.prepare("SELECT run_id FROM operation_journal WHERE kind='provider-dispatch' AND status IN ('in_progress','uncertain') LIMIT 1").get();
318
+ if (uncertain)
319
+ throw new Error(`Build ${uncertain.run_id} has unresolved provider dispatch; reconcile before another launch`);
320
+ for (const row of this.db.prepare("SELECT DISTINCT run_id FROM build_owned_processes WHERE state<>'quiescent'").all()) {
321
+ if (this.unresolvedPreparationProcesses(row.run_id).length)
322
+ throw new Error(`Build ${row.run_id} has unverified preparation descendants; reconcile before another launch`);
323
+ }
324
+ const prior = this.buildAdmission();
325
+ if (prior && this.unresolvedPreparationProcesses(prior.runId).length)
326
+ throw new Error(`Build ${prior.runId} has unverified preparation descendants; reconcile before another launch`);
327
+ if (prior && [...this.unresolvedRoleDispatches(prior.runId, "builder"), ...this.unresolvedRoleDispatches(prior.runId, "qa")].length)
328
+ throw new Error(`Build ${prior.runId} has unresolved provider dispatch; reconcile before another launch`);
329
+ this.assertExecutionProtocol();
330
+ this.ensureRun(runId, "build");
331
+ const authority = acquireAdmission(this.db, this.projectDir, runId, phase);
332
+ this.writerAuthority = authority;
333
+ rememberBuildAuthority(authority);
334
+ return authority;
335
+ });
336
+ }
337
+ acquireBuildRecoveryAdmission(runId) {
338
+ return this.atomic(() => {
339
+ this.assertExecutionProtocol();
340
+ this.assertNoLegacyBuildOwner();
341
+ const otherDispatch = this.db.prepare("SELECT run_id FROM operation_journal WHERE run_id<>? AND kind='provider-dispatch' AND status IN ('in_progress','uncertain') LIMIT 1").get(runId);
342
+ if (otherDispatch)
343
+ throw new Error(`Build ${otherDispatch.run_id} has unresolved provider dispatch; reconcile before starting another recovery workflow`);
344
+ const current = localBuildAuthority(this.projectDir);
345
+ if (current && current.runId === runId && this.buildAdmission()?.token === current.token) {
346
+ this.writerAuthority = current;
347
+ return current;
348
+ }
349
+ const prior = this.buildAdmission();
350
+ if (prior && prior.runId !== runId)
351
+ return this.acquireBuildAdmission(runId, "coordinator");
352
+ if (this.unresolvedPreparationProcesses(runId).length)
353
+ throw new Error("Owned preparation descendants require reconciliation before recovery");
354
+ const lease = this.currentLease();
355
+ if (lease && classifyProcess(lease.pid, lease.processStart, lease.host).state !== "dead")
356
+ throw new Error(`project workflow lease is held by ${lease.owner} for run ${lease.runId}`);
357
+ for (const owner of this.runningSupervisors())
358
+ if (owner.state.pid && classifyProcess(owner.state.pid, owner.state.processStart).state !== "dead")
359
+ throw new Error(`supervisor already active for project (run ${owner.runId})`);
360
+ const authority = acquireAdmission(this.db, this.projectDir, runId, "coordinator");
361
+ this.writerAuthority = authority;
362
+ rememberBuildAuthority(authority);
363
+ return authority;
364
+ });
365
+ }
366
+ admitLeasedBuild(lease) {
367
+ return this.atomic(() => {
368
+ this.assertExecutionProtocol();
369
+ const held = this.currentLease();
370
+ if (!held || held.owner !== lease.owner || held.generation !== lease.generation || held.runId !== lease.runId)
371
+ throw new Error("workflow lease ownership changed");
372
+ const existing = localBuildAuthority(this.projectDir);
373
+ const authority = existing && existing.runId === lease.runId && this.buildAdmission()?.token === existing.token ? existing : acquireAdmission(this.db, this.projectDir, lease.runId, "coordinator");
374
+ this.writerAuthority = authority;
375
+ rememberBuildAuthority(authority);
376
+ return authority;
377
+ });
378
+ }
379
+ reacquireBuildCoordinator(runId) {
380
+ return this.atomic(() => {
381
+ const owner = this.supervisorState(runId);
382
+ if (owner?.pid !== process.pid || owner.processStart !== processStartIdentity())
383
+ throw new Error("Supervisor ownership changed");
384
+ const authority = acquireAdmission(this.db, this.projectDir, runId, "coordinator");
385
+ this.writerAuthority = authority;
386
+ rememberBuildAuthority(authority);
387
+ return authority;
388
+ });
389
+ }
390
+ assertBuildAdmission(authority) { assertAdmission(this.db, authority); }
391
+ releaseBuildAdmission(authority) {
392
+ this.atomic(() => {
393
+ assertAdmission(this.db, authority);
394
+ if (this.unresolvedPreparationProcesses(authority.runId).length)
395
+ throw new Error("Unresolved owned processes or provider dispatch require reconciliation");
396
+ releaseAdmission(this.db, authority);
397
+ forgetBuildAuthority(authority);
398
+ if (this.writerAuthority?.token === authority.token)
399
+ this.writerAuthority = undefined;
400
+ });
401
+ }
402
+ reserveBuildLaunch(authority, role, digest, protocol) { return reserveLaunch(this.db, authority, role, digest, protocol); }
403
+ dispatchBuildLaunch(authority, token) { setLaunchState(this.db, authority, token, "dispatching"); }
404
+ failBuildLaunch(authority, token) { setLaunchState(this.db, authority, token, "failed"); }
405
+ claimBuildLaunch(runId, token, role, digest) { const authority = claimLaunch(this.db, this.projectDir, runId, token, role, digest); this.writerAuthority = authority; rememberBuildAuthority(authority); return authority; }
406
+ registerBuildLaunchChild(token) { registerLaunchChild(this.db, token, this.projectDir); }
407
+ acknowledgeBuildLaunchChild(authority, token, pid) { acknowledgeLaunchChild(this.db, authority, token, pid); }
408
+ reconcileBuildLaunches(runId, original) {
409
+ return this.atomic(() => {
410
+ let result = "retired";
411
+ for (const row of this.db.prepare("SELECT token FROM build_launches WHERE run_id=? AND state IN ('reserved','dispatching')").all(runId)) {
412
+ const state = reconcileLaunch(this.db, row.token, original);
413
+ if (state === "unknown")
414
+ return state;
415
+ if (state === "claimed")
416
+ result = state;
417
+ }
418
+ return result;
419
+ });
420
+ }
421
+ buildLaunch(token) { return readLaunch(this.db, token); }
422
+ preparationSuccessor(runId) {
423
+ const seen = new Set();
424
+ let current = runId;
425
+ while (true) {
426
+ if (seen.has(current))
427
+ throw new Error("Preparation retry lineage contains a cycle");
428
+ seen.add(current);
429
+ const link = this.db.prepare("SELECT successor,project FROM build_retry_lineage WHERE predecessor=?").get(current);
430
+ if (!link)
431
+ return current;
432
+ if (link.project !== canonicalProject(this.projectDir))
433
+ throw new Error("Preparation lineage belongs to another project");
434
+ current = link.successor;
435
+ }
436
+ }
437
+ reservePreparationRetry(predecessor, args, expectedStartArgs) {
438
+ return this.atomic(() => {
439
+ this.lineageTransition++;
440
+ try {
441
+ const existing = this.preparationSuccessor(predecessor);
442
+ if (existing !== predecessor)
443
+ throw new Error(`Preparation already retried as ${existing}; select that successor instead`);
444
+ const eligibility = this.preparationEligibility(predecessor);
445
+ if (!eligibility.eligible)
446
+ throw new Error(`Preparation requires reconciliation: ${eligibility.reasons.join("; ")}`);
447
+ const previous = this.getRun(predecessor);
448
+ if (expectedStartArgs !== undefined && JSON.stringify(previous.state.startArgs ?? null) !== expectedStartArgs)
449
+ throw new Error("Saved preparation options changed before launch reservation; select the build again with rafi resume");
450
+ const authority = this.acquireBuildAdmission(predecessor, "coordinator");
451
+ if (typeof previous.state.predecessor === "string") {
452
+ this.transition(predecessor, { checkpoint: "preparing", state: { ...previous.state, startArgs: args }, event: "preparation_relaunch_reserved" });
453
+ return { runId: predecessor, authority, launch: reserveLaunch(this.db, authority, "coordinator", launchDigest(args), "registered-v2") };
454
+ }
455
+ const runId = randomUUID();
456
+ this.ensureRun(runId, "build");
457
+ for (const row of this.db.prepare("SELECT decision_key,decision_json,created_at,updated_at FROM human_decisions WHERE run_id=? AND status='answered'").all(predecessor)) {
458
+ if (!row.decision_key.startsWith(`${predecessor}:`))
459
+ throw new Error("Legacy approval key cannot be attributed safely to retry scope");
460
+ const decision = { ...JSON.parse(row.decision_json), decisionId: randomUUID(), runId };
461
+ this.db.prepare("INSERT INTO human_decisions VALUES(?,?,?,'answered',?,?,?)").run(decision.decisionId, `${runId}:${row.decision_key.slice(predecessor.length + 1)}`, runId, JSON.stringify(decision), row.created_at, row.updated_at);
462
+ }
463
+ const policy = this.autonomyPolicy(predecessor);
464
+ if (policy)
465
+ this.freezeAutonomyPolicy(runId, policy);
466
+ const supervisor = this.supervisorState(predecessor);
467
+ if (supervisor)
468
+ this.putSupervisorState(runId, { ...supervisor, status: "stopped", pid: undefined, workerPid: undefined, processStart: undefined });
469
+ this.transition(runId, { checkpoint: "preparing", state: { ...previous.state, startArgs: args, predecessor }, event: "preparation_retry_reserved" });
470
+ this.db.prepare("INSERT INTO build_retry_lineage VALUES(?,?,?,?)").run(predecessor, runId, canonicalProject(this.projectDir), new Date().toISOString());
471
+ this.transition(predecessor, { status: "superseded", checkpoint: "preparation-superseded", state: { ...previous.state, successor: runId }, event: "preparation_superseded", payload: { successor: runId } });
472
+ const next = { ...authority, runId };
473
+ this.db.prepare("UPDATE build_admission SET record_json=? WHERE singleton=1").run(JSON.stringify(next));
474
+ this.writerAuthority = next;
475
+ rememberBuildAuthority(next);
476
+ return { runId, authority: next, launch: reserveLaunch(this.db, next, "coordinator", launchDigest(args), "registered-v2") };
477
+ }
478
+ finally {
479
+ this.lineageTransition--;
480
+ }
481
+ });
482
+ }
483
+ beginOwnedPreparationProcess(authority, tag, gated = false) {
484
+ return this.atomic(() => {
485
+ assertAdmission(this.db, authority);
486
+ const id = tag ?? randomUUID();
487
+ if (gated && this.db.prepare("SELECT version FROM build_ownership_schema").get().version !== 3)
488
+ throw new Error("Legacy build ownership needs reconciliation before readiness protocol migration");
489
+ if (this.unresolvedPreparationProcesses(authority.runId).length)
490
+ throw new Error("Previous readiness cleanup is unresolved");
491
+ this.db.prepare("INSERT INTO build_owned_processes(id,run_id,owner,pid,process_start,host,state,outcome_json) VALUES(?,?,?,NULL,NULL,?,'intended',?)").run(id, authority.runId, authority.token, authority.host, JSON.stringify(gated ? { protocol: "gated-v3", startup: "intended", revision: 0, authority, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() } : { protocol: process.platform === "win32" ? "windows-job-v1" : "tagged-v2" }));
492
+ return id;
493
+ });
494
+ }
495
+ recordOwnedPreparationProcess(authority, id, pid) {
496
+ this.atomic(() => {
497
+ assertAdmission(this.db, authority);
498
+ const row = this.readinessProcesses().find(row => row.id === id);
499
+ if (row && readinessMetadata(row).protocol === "gated-v3")
500
+ throw new Error("Gated readiness requires helper registration");
501
+ if (this.db.prepare("UPDATE build_owned_processes SET pid=?,process_start=?,state='running' WHERE id=? AND owner=? AND state='intended'").run(pid, processStartIdentity(pid), id, authority.token).changes !== 1)
502
+ throw new Error("Owned process launch changed");
503
+ });
504
+ }
505
+ finishOwnedPreparationProcess(authority, id, noChild = false, outcome) {
506
+ this.atomic(() => {
507
+ assertAdmission(this.db, authority);
508
+ const row = this.db.prepare("SELECT * FROM build_owned_processes WHERE id=? AND owner=?").get(id, authority.token);
509
+ if (!row)
510
+ throw new Error("Owned process record disappeared");
511
+ const processRow = this.db.prepare("SELECT * FROM build_owned_processes WHERE id=?").get(id);
512
+ const meta = readinessMetadata(processRow);
513
+ if (outcome)
514
+ this.db.prepare("UPDATE build_owned_processes SET outcome_json=? WHERE id=?").run(JSON.stringify({ ...meta, ...outcome }), id);
515
+ if ((noChild && row.pid === null && meta.protocol !== "gated-v3") || inspectReadiness(processRow).state === "quiescent")
516
+ this.db.prepare("UPDATE build_owned_processes SET state='quiescent' WHERE id=?").run(id);
517
+ });
518
+ }
519
+ readinessProcesses(runId) {
520
+ return this.db.prepare("SELECT * FROM build_owned_processes WHERE state<>'quiescent'" + (runId ? " AND run_id=?" : "")).all(...(runId ? [runId] : []));
521
+ }
522
+ unresolvedPreparationProcesses(runId) {
523
+ const parent = this.db.prepare("SELECT parent FROM build_child_runs WHERE child=?").get(runId)?.parent;
524
+ return this.readinessProcesses().filter(row => row.run_id === runId || row.run_id === parent).map(row => row.id);
525
+ }
526
+ registerReadinessHelper(id) {
527
+ this.atomic(() => {
528
+ const row = this.readinessProcesses().find(row => row.id === id);
529
+ const meta = row && readinessMetadata(row);
530
+ const start = processStartIdentity();
531
+ if (!row || meta?.protocol !== "gated-v3" || meta.startup !== "intended" || row.pid || row.host !== hostname() || start === "unavailable")
532
+ throw new Error("Readiness registration rejected");
533
+ this.db.prepare("UPDATE build_owned_processes SET pid=?,process_start=?,outcome_json=? WHERE id=?").run(process.pid, start, JSON.stringify({ ...meta, startup: "registered", revision: meta.revision + 1, updatedAt: new Date().toISOString() }), id);
534
+ });
535
+ }
536
+ authorizeReadinessHelper(authority, id, pid) {
537
+ this.atomic(() => {
538
+ assertAdmission(this.db, authority);
539
+ const row = this.readinessProcesses().find(row => row.id === id);
540
+ const meta = row && readinessMetadata(row);
541
+ if (!row || row.owner !== authority.token || meta?.startup !== "registered" || row.pid !== pid || classifyProcess(pid, row.process_start ?? undefined, row.host).state !== "live")
542
+ throw new Error("Readiness authorization rejected");
543
+ this.db.prepare("UPDATE build_owned_processes SET outcome_json=? WHERE id=?").run(JSON.stringify({ ...meta, startup: "authorized", revision: meta.revision + 1, updatedAt: new Date().toISOString() }), id);
544
+ });
545
+ }
546
+ assertReadinessHelper(id) {
547
+ const row = this.readinessProcesses().find(row => row.id === id);
548
+ if (!row || readinessMetadata(row).startup !== "authorized" || row.pid !== process.pid || row.process_start !== processStartIdentity() || row.host !== hostname())
549
+ throw new Error("Readiness execution capability rejected");
550
+ return row;
551
+ }
552
+ recordReadinessCreator(id, pid) {
553
+ this.atomic(() => {
554
+ const row = this.assertReadinessHelper(id);
555
+ const meta = readinessMetadata(row);
556
+ const start = processStartIdentity(pid);
557
+ if (meta.creator || start === "unavailable")
558
+ throw new Error("Windows creator registration rejected");
559
+ this.db.prepare("UPDATE build_owned_processes SET outcome_json=? WHERE id=?").run(JSON.stringify({ ...meta, creator: { pid, start }, revision: meta.revision + 1, updatedAt: new Date().toISOString() }), id);
560
+ });
561
+ }
562
+ assertReadinessRelationships(runId) {
563
+ // Inspect each directed lineage separately; cleanup never broadens to siblings.
564
+ for (const relation of ["parent", "successor", "predecessor"]) {
565
+ let current = runId;
566
+ const seen = new Set();
567
+ while (true) {
568
+ if (seen.has(current))
569
+ throw new Error("Readiness run relationship contains a cycle");
570
+ seen.add(current);
571
+ const link = relation === "parent"
572
+ ? this.db.prepare("SELECT parent AS target FROM build_child_runs WHERE child=?").get(current)
573
+ : this.db.prepare(`SELECT ${relation} AS target,project FROM build_retry_lineage WHERE ${relation === "successor" ? "predecessor" : "successor"}=?`).get(current);
574
+ if (!link)
575
+ break;
576
+ if (!this.getRun(link.target) || (link.project && canonicalProject(link.project) !== canonicalProject(this.projectDir)))
577
+ throw new Error("Readiness relationship is missing or belongs to another project");
578
+ current = link.target;
579
+ }
580
+ }
581
+ }
582
+ readinessCleanupDetails(runId) {
583
+ return this.readinessProcesses(runId).map(row => {
584
+ const meta = readinessMetadata(row);
585
+ try {
586
+ this.assertReadinessRelationships(runId);
587
+ this.assertReadinessCleanupOwner(row);
588
+ }
589
+ catch (error) {
590
+ return `${row.id}: ${error.message}`;
591
+ }
592
+ return `${row.id}: ${meta.cleanup?.reason ?? inspectReadiness(row).reason}`;
593
+ });
594
+ }
595
+ assertReadinessCleanupOwner(row, original) {
596
+ const held = this.buildAdmission();
597
+ if (original && held?.token === original.token && row.owner === original.token && original.project === canonicalProject(this.projectDir) && held.project === original.project)
598
+ return;
599
+ const recorded = readinessMetadata(row).authority;
600
+ const owner = recorded?.token === row.owner ? recorded : held?.token === row.owner ? held : undefined;
601
+ if (owner && (owner.project !== canonicalProject(this.projectDir) || owner.host !== row.host))
602
+ throw new Error("Readiness ownership belongs to another project or host");
603
+ if (!owner || classifyProcess(owner.pid, owner.processStart, owner.host).state !== "dead")
604
+ throw new Error("Readiness owner is live, unknown, or lacks attributable provenance");
605
+ }
606
+ revokeReadinessHelper(id, original) {
607
+ this.atomic(() => {
608
+ const row = this.readinessProcesses().find(row => row.id === id);
609
+ if (!row)
610
+ return;
611
+ this.assertReadinessCleanupOwner(row, original);
612
+ const meta = readinessMetadata(row);
613
+ if (meta.protocol !== "gated-v3" || !["intended", "registered", "revoked"].includes(meta.startup))
614
+ throw new Error("Authorized readiness cannot be revoked; verify cleanup instead");
615
+ if (meta.startup === "revoked")
616
+ return;
617
+ this.db.prepare("UPDATE build_owned_processes SET outcome_json=? WHERE id=?").run(JSON.stringify({ ...meta, startup: "revoked", revision: meta.revision + 1, updatedAt: new Date().toISOString() }), id);
618
+ });
619
+ }
620
+ async reconcileReadiness(runId, original, deadline = Date.now() + 5000) {
621
+ // Exact run scope: related/different blockers remain independently selectable.
622
+ for (const initial of this.readinessProcesses(runId)) {
623
+ if (Date.now() >= deadline)
624
+ break;
625
+ let row = initial;
626
+ try {
627
+ this.atomic(() => {
628
+ this.assertReadinessRelationships(runId);
629
+ this.assertReadinessCleanupOwner(row, original);
630
+ const meta = readinessMetadata(row);
631
+ if (meta.protocol === "gated-v3" && ["intended", "registered"].includes(meta.startup))
632
+ this.revokeReadinessHelper(row.id, original);
633
+ });
634
+ row = this.readinessProcesses(runId).find(item => item.id === row.id);
635
+ if (!row)
636
+ continue;
637
+ const meta = readinessMetadata(row);
638
+ let evidence = await cleanupReadiness(row, deadline);
639
+ // tagged-v2 launched directly from its recorded owner, without a delayed helper.
640
+ if (!row.pid && meta.protocol === "tagged-v2" && row.host === hostname() && (() => { const owner = this.buildAdmission(); return owner?.token === row.owner && classifyProcess(owner.pid, owner.processStart, owner.host).state === "dead"; })() && taggedProcesses(row.id)?.length === 0)
641
+ evidence = { state: "quiescent", reason: "verified dead direct-spawn owner and complete empty tag inventory" };
642
+ this.atomic(() => {
643
+ this.assertReadinessRelationships(runId);
644
+ this.assertReadinessCleanupOwner(row, original);
645
+ const current = this.readinessProcesses(runId).find(item => item.id === row.id);
646
+ if (!current || current.outcome_json !== row.outcome_json || current.pid !== row.pid)
647
+ return;
648
+ this.db.prepare("UPDATE build_owned_processes SET state=?,outcome_json=? WHERE id=?").run(evidence.state === "quiescent" ? "quiescent" : row.state, JSON.stringify({ ...meta, cleanup: { ...evidence, checkedAt: new Date().toISOString() }, revision: (meta.revision ?? 0) + 1, updatedAt: new Date().toISOString() }), row.id);
649
+ });
650
+ }
651
+ catch { /* Preserve durable blocker, including after storage/authority failures. */ }
652
+ }
653
+ return this.readinessProcesses(runId).map(row => row.id);
654
+ }
655
+ preparationEligibility(runId) {
656
+ const run = this.getRun(runId);
657
+ const reasons = [];
658
+ for (const row of this.db.prepare("SELECT state_json FROM workflow_runs WHERE kind='legacy' AND legacy=1").all()) {
659
+ const legacy = parseJson(row.state_json);
660
+ if (legacy.source && /delivery-sessions/.test(legacy.source) && existsSync(legacy.source) && legacy.record?.sessionId)
661
+ reasons.push("unattributed legacy branch session requires reconciliation");
662
+ }
663
+ if (this.pendingHumanDecisions(runId).length)
664
+ reasons.push("pending decisions require an answer");
665
+ if (this.unresolvedPreparationProcesses(runId).length)
666
+ reasons.push("owned preparation processes are still running or unverified");
667
+ if (this.incompletePublications().some(item => item.runId === runId))
668
+ reasons.push("unfinished publication requires reconciliation");
669
+ if (!run || ["completed", "cancelled", "superseded"].includes(run.status))
670
+ reasons.push("run is missing or terminal");
671
+ if (run?.state.version !== undefined || run?.state.runId)
672
+ reasons.push("implementation snapshot exists");
673
+ const tables = ["operation_journal", "provider_sessions", "continuity_heads", "handoffs", "role_mutation_leases", "branch_resume_sessions", "qa_ticket_heads", "qa_recovery_heads", "recovery_attempts"];
674
+ for (const table of tables)
675
+ if (this.db.prepare(`SELECT 1 FROM ${table} WHERE run_id=? LIMIT 1`).get(runId))
676
+ reasons.push(`${table} contains execution evidence or decisions`);
677
+ return { eligible: reasons.length === 0, reasons };
678
+ }
77
679
  /** Ensure non-workflow build records can use the same durable event store. */
78
680
  ensureRun(runId, kind = "build", now = new Date()) {
79
681
  const existing = this.getRun(runId);
@@ -81,8 +683,14 @@ export class WorkflowDb {
81
683
  return existing;
82
684
  const at = now.toISOString();
83
685
  this.db.transaction(() => {
686
+ if (this.writerAuthority)
687
+ assertAdmission(this.db, this.writerAuthority);
84
688
  const inserted = this.db.prepare(`INSERT OR IGNORE INTO workflow_runs(run_id,kind,status,checkpoint,original_work_json,remaining_work_json,state_json,legacy,created_at,updated_at)
85
689
  VALUES(?,?,'running','durable-baseline','{}','{}','{}',0,?,?)`).run(runId, kind, at, at);
690
+ if (inserted.changes && this.writerAuthority && this.writerAuthority.runId !== runId) {
691
+ this.db.prepare("INSERT INTO build_child_runs VALUES(?,?)").run(runId, this.writerAuthority.runId);
692
+ this.db.prepare("INSERT OR IGNORE INTO build_runtime_runs VALUES(?)").run(runId);
693
+ }
86
694
  if (inserted.changes)
87
695
  this.insertEvent(runId, "durable_baseline", "durable-baseline", { source: "host" }, at);
88
696
  })();
@@ -96,8 +704,14 @@ export class WorkflowDb {
96
704
  legacy: Boolean(input.legacy), createdAt: at, updatedAt: at,
97
705
  };
98
706
  this.db.transaction(() => {
707
+ if (this.writerAuthority)
708
+ assertAdmission(this.db, this.writerAuthority);
99
709
  this.db.prepare(`INSERT INTO workflow_runs(run_id,kind,status,checkpoint,original_work_json,remaining_work_json,state_json,legacy,created_at,updated_at)
100
710
  VALUES(?,?,?,?,?,?,?,?,?,?)`).run(run.runId, run.kind, run.status, run.checkpoint, json(run.originalWork), json(run.remainingWork), json(run.state), run.legacy ? 1 : 0, at, at);
711
+ if (this.writerAuthority && this.writerAuthority.runId !== run.runId) {
712
+ this.db.prepare("INSERT INTO build_child_runs VALUES(?,?)").run(run.runId, this.writerAuthority.runId);
713
+ this.db.prepare("INSERT OR IGNORE INTO build_runtime_runs VALUES(?)").run(run.runId);
714
+ }
101
715
  this.insertEvent(run.runId, "run_created", run.checkpoint, { kind: run.kind }, at);
102
716
  })();
103
717
  return run;
@@ -126,12 +740,45 @@ export class WorkflowDb {
126
740
  : this.db.prepare("SELECT * FROM workflow_runs WHERE status NOT IN ('completed','cancelled','superseded') ORDER BY created_at").all();
127
741
  return rows.map(rowToRun);
128
742
  }
743
+ /** Supervisors may pause a worker generation; they cannot publish worker state. */
744
+ transitionSupervisor(runId, workerGeneration, update) {
745
+ return this.atomic(() => {
746
+ const owner = this.supervisorState(runId);
747
+ if (!owner || owner.pid !== process.pid || owner.processStart !== processStartIdentity() || owner.workerGeneration !== workerGeneration)
748
+ throw new Error("Supervisor generation no longer owns the lifecycle transition");
749
+ this.coordinatorTransition++;
750
+ try {
751
+ return this.transition(runId, update);
752
+ }
753
+ finally {
754
+ this.coordinatorTransition--;
755
+ }
756
+ });
757
+ }
129
758
  transition(runId, update, now = new Date()) {
130
759
  const at = now.toISOString();
131
760
  return this.db.transaction(() => {
761
+ if (!this.coordinatorTransition && (this.writerAuthority || this.buildAdmission())) {
762
+ if (!this.writerAuthority)
763
+ throw new Error("Workflow transition requires original build admission authority");
764
+ assertAdmission(this.db, this.writerAuthority);
765
+ const supersession = update.status === "superseded" && update.state?.supersededBy === this.writerAuthority.runId && this.getRun(this.writerAuthority.runId)?.kind === "recovery";
766
+ if (!this.lineageTransition && this.writerAuthority.runId !== runId && !this.db.prepare("SELECT 1 FROM build_child_runs WHERE child=? AND parent=?").get(runId, this.writerAuthority.runId) && !supersession)
767
+ throw new Error("Workflow authority belongs to another run");
768
+ }
132
769
  const current = this.getRun(runId);
133
770
  if (!current)
134
771
  throw new Error(`workflow run not found: ${runId}`);
772
+ if (["completed", "cancelled", "superseded"].includes(current.status) && update.status && update.status !== current.status)
773
+ throw new Error(`Cannot reactivate terminal ${current.status} workflow`);
774
+ if (!this.coordinatorTransition && this.db.prepare("SELECT 1 FROM build_runtime_runs WHERE run_id=?").get(runId) && !this.writerAuthority) {
775
+ const lease = this.currentLease();
776
+ if (!this.writerLease || !lease || lease.owner !== this.writerLease.owner || lease.generation !== this.writerLease.generation)
777
+ throw new Error("Workflow transition requires original build authority");
778
+ const supersession = update.status === "superseded" && update.state?.supersededBy === lease.runId && this.getRun(lease.runId)?.kind === "recovery";
779
+ if (lease.runId !== runId && !supersession)
780
+ throw new Error("Workflow authority belongs to another run");
781
+ }
135
782
  const next = { ...current, status: update.status ?? current.status, checkpoint: update.checkpoint, remainingWork: update.remainingWork ?? current.remainingWork, state: update.state ?? current.state, updatedAt: at };
136
783
  this.db.prepare("UPDATE workflow_runs SET status=?,checkpoint=?,remaining_work_json=?,state_json=?,updated_at=? WHERE run_id=?")
137
784
  .run(next.status, next.checkpoint, json(next.remainingWork), json(next.state), at, runId);
@@ -206,6 +853,9 @@ export class WorkflowDb {
206
853
  this.ensureRun(input.runId, "build", now);
207
854
  const at = now.toISOString();
208
855
  const safePayload = sanitizeContinuityValue(input.payload);
856
+ const graphAccess = currentGraphDerivedAccess();
857
+ if (graphAccess)
858
+ registerGraphDerived(this.db, json(safePayload), graphAccess);
209
859
  const session = sessionParts(input.sessionRef, undefined, input.sessionKey);
210
860
  const digest = digestJson({ runId: input.runId, role: input.role, kind: input.kind, payload: safePayload, authoritativeStateRevision: input.authoritativeStateRevision, sessionKey: session.key, at });
211
861
  const result = this.db.prepare(`INSERT INTO continuity_events(run_id,role,kind,payload_json,digest,authoritative_state_revision,session_key,session_ref_json,created_at)
@@ -214,6 +864,8 @@ export class WorkflowDb {
214
864
  }
215
865
  continuityEvents(runId, afterSequence = 0) {
216
866
  const rows = this.db.prepare("SELECT * FROM continuity_events WHERE run_id=? AND sequence>? ORDER BY sequence").all(runId, afterSequence);
867
+ for (const row of rows)
868
+ this.assertGraphContinuityAccess(row.payload_json);
217
869
  return rows.map(continuityEventFromRow);
218
870
  }
219
871
  publishContinuityCheckpoint(input, now = new Date()) {
@@ -223,6 +875,9 @@ export class WorkflowDb {
223
875
  const previous = this.continuityHead(input.runId, input.role);
224
876
  const latestEvent = this.db.prepare("SELECT COALESCE(MAX(sequence),0) AS sequence FROM continuity_events WHERE run_id=?").get(input.runId);
225
877
  const safeDelta = sanitizeContinuityValue(input.delta);
878
+ const graphAccess = currentGraphDerivedAccess();
879
+ if (graphAccess)
880
+ registerGraphDerived(this.db, json(safeDelta), graphAccess);
226
881
  const session = sessionParts(input.sessionRef, undefined, input.sessionKey);
227
882
  const digest = digestJson({ runId: input.runId, role: input.role, sequence: latestEvent.sequence, predecessorDigest: previous?.digest, delta: safeDelta, authoritativeStateRevision: input.authoritativeStateRevision, sessionKey: session.key });
228
883
  const result = this.db.prepare(`INSERT INTO continuity_checkpoints(run_id,role,event_sequence,state,delta_json,digest,predecessor_digest,authoritative_state_revision,session_key,session_ref_json,created_at)
@@ -244,12 +899,21 @@ export class WorkflowDb {
244
899
  const rows = role
245
900
  ? this.db.prepare("SELECT * FROM continuity_checkpoints WHERE run_id=? AND role=? ORDER BY checkpoint_id").all(runId, role)
246
901
  : this.db.prepare("SELECT * FROM continuity_checkpoints WHERE run_id=? ORDER BY checkpoint_id").all(runId);
902
+ for (const row of rows)
903
+ this.assertGraphContinuityAccess(row.delta_json);
247
904
  return rows.map(continuityCheckpointFromRow);
248
905
  }
249
906
  latestContinuityCheckpoint(runId, role) {
250
907
  const row = this.db.prepare("SELECT * FROM continuity_checkpoints WHERE run_id=? AND role=? ORDER BY checkpoint_id DESC LIMIT 1").get(runId, role);
908
+ if (row)
909
+ this.assertGraphContinuityAccess(row.delta_json);
251
910
  return row ? continuityCheckpointFromRow(row) : undefined;
252
911
  }
912
+ assertGraphContinuityAccess(value) {
913
+ const digest = createHash("sha256").update(value).digest("hex");
914
+ if (!graphDerivedAllowed(this.db, this.projectDir, digest))
915
+ throw new Error("Continuity contains revoked graph-derived evidence; explicit source-based recovery is required");
916
+ }
253
917
  continuityHead(runId, role) {
254
918
  const row = this.db.prepare("SELECT * FROM continuity_heads WHERE run_id=? AND role=?").get(runId, role);
255
919
  return row ? continuityHeadFromRow(row) : undefined;
@@ -379,6 +1043,18 @@ export class WorkflowDb {
379
1043
  })();
380
1044
  return decision;
381
1045
  }
1046
+ cancelPendingHumanDecisions(runId, reason, now = new Date()) {
1047
+ this.atomic(() => {
1048
+ for (const decision of this.pendingHumanDecisions(runId)) {
1049
+ const next = { ...decision, status: "cancelled", cancellationReason: reason, cancelledAt: now.toISOString() };
1050
+ this.db.prepare("UPDATE human_decisions SET status='cancelled',decision_json=?,updated_at=? WHERE decision_id=? AND status='pending'").run(json(next), now.toISOString(), decision.decisionId);
1051
+ this.insertEvent(runId, "human_decision_cancelled", "cancelled", { decisionId: decision.decisionId, reason }, now.toISOString());
1052
+ }
1053
+ });
1054
+ }
1055
+ humanDecisionKey(decisionId) {
1056
+ return this.db.prepare("SELECT decision_key FROM human_decisions WHERE decision_id=?").get(decisionId)?.decision_key;
1057
+ }
382
1058
  humanDecision(decisionId) {
383
1059
  const row = this.db.prepare("SELECT decision_json FROM human_decisions WHERE decision_id=?").get(decisionId);
384
1060
  return row ? parseJson(row.decision_json) : undefined;
@@ -387,11 +1063,50 @@ export class WorkflowDb {
387
1063
  return this.db.prepare("SELECT decision_json FROM human_decisions WHERE run_id=? AND status='pending' ORDER BY created_at").all(runId)
388
1064
  .map((row) => parseJson(row.decision_json));
389
1065
  }
1066
+ decisionContinuationAvailable(runId, decisionId) {
1067
+ const prior = this.operations(runId).filter(op => op.kind === "decision-continuation" && op.intent.decisionId === decisionId);
1068
+ return prior.every(op => op.status === "failed" && op.result?.dispatchState === "not-sent");
1069
+ }
1070
+ nextDecisionContinuationKey(runId, decisionId) {
1071
+ if (!this.decisionContinuationAvailable(runId, decisionId))
1072
+ throw new Error("Decision continuation requires reconciliation before replay");
1073
+ const count = this.operations(runId).filter(op => op.kind === "decision-continuation" && op.intent.decisionId === decisionId).length;
1074
+ return `decision-continuation:${decisionId}${count ? `:retry-${count}` : ""}`;
1075
+ }
390
1076
  answeredTicketDecisions(runId, scopeRevision) {
391
1077
  return this.db.prepare("SELECT decision_key,decision_json FROM human_decisions WHERE run_id=? AND status='answered'").all(runId)
392
1078
  .filter(row => row.decision_key.includes(`:${scopeRevision}:`))
393
1079
  .map(row => parseJson(row.decision_json))
394
- .filter(decision => decision.interruptionId.startsWith("ticket:") && !this.operation(`decision-continuation:${decision.decisionId}`));
1080
+ .filter(decision => decision.interruptionId.startsWith("ticket:") && this.decisionContinuationAvailable(runId, decision.decisionId) && !this.operation(`decision-supersession:${decision.decisionId}`));
1081
+ }
1082
+ /** Unconsumed answers to an older definition must not silently reopen a ticket. */
1083
+ staleTicketDecisions(runId, scopeRevision) {
1084
+ return this.db.prepare("SELECT decision_key,decision_json FROM human_decisions WHERE run_id=? AND status='answered'").all(runId)
1085
+ .filter(row => !row.decision_key.includes(`:${scopeRevision}:`))
1086
+ .map(row => parseJson(row.decision_json))
1087
+ .filter(decision => decision.interruptionId.startsWith("ticket:") && this.decisionContinuationAvailable(runId, decision.decisionId) && !this.operation(`decision-supersession:${decision.decisionId}`));
1088
+ }
1089
+ refreshStaleTicketDecisions(runId, scopeRevision, tickets) {
1090
+ this.atomic(() => {
1091
+ for (const prior of this.pendingHumanDecisions(runId)) {
1092
+ const key = this.humanDecisionKey(prior.decisionId) ?? "";
1093
+ if (!prior.interruptionId.startsWith("ticket:") || tickets && !tickets.includes(prior.interruptionId.slice(7)) || key.includes(`:${scopeRevision}:`) || !(key.startsWith(`${runId}:builder:`) || key.startsWith(`${runId}:ticket-question:`)))
1094
+ continue;
1095
+ this.ensureHumanDecision({ runId, decisionKey: `${runId}:ticket-question:${scopeRevision}:supersedes:${prior.decisionId}`, interruptionId: prior.interruptionId, prompt: prior.prompt, choices: prior.choices, evidence: prior.evidence });
1096
+ this.db.prepare("UPDATE human_decisions SET status='cancelled',decision_json=?,updated_at=? WHERE decision_id=? AND status='pending'").run(json({ ...prior, status: "cancelled", cancellationReason: "ticket scope changed before the question was answered" }), new Date().toISOString(), prior.decisionId);
1097
+ this.insertEvent(runId, "human_decision_superseded", "question-revised", { decisionId: prior.decisionId, scopeRevision }, new Date().toISOString());
1098
+ }
1099
+ for (const prior of this.staleTicketDecisions(runId, scopeRevision)) {
1100
+ if (tickets && !tickets.includes(prior.interruptionId.slice(7)))
1101
+ continue;
1102
+ const next = this.ensureHumanDecision({ runId, decisionKey: `${runId}:ticket-question:${scopeRevision}:supersedes:${prior.decisionId}`, interruptionId: prior.interruptionId,
1103
+ prompt: prior.prompt, choices: prior.choices, evidence: prior.evidence });
1104
+ const key = `decision-supersession:${prior.decisionId}`;
1105
+ this.planOperation({ runId, idempotencyKey: key, kind: "decision-supersession", intent: { priorDecisionId: prior.decisionId, replacementDecisionId: next.decisionId, reason: "ticket scope changed before answer was used" } });
1106
+ this.updateOperation(key, "in_progress");
1107
+ this.updateOperation(key, "confirmed");
1108
+ }
1109
+ });
395
1110
  }
396
1111
  answerHumanDecision(runId, decisionId, choiceId, now = new Date(), answer) {
397
1112
  return this.atomic(() => {
@@ -431,6 +1146,8 @@ export class WorkflowDb {
431
1146
  }
432
1147
  planOperation(input, now = new Date()) {
433
1148
  const at = now.toISOString();
1149
+ if (input.kind === "provider-dispatch" && this.unresolvedPreparationProcesses(input.runId).length)
1150
+ throw new Error("Readiness cleanup must be reconciled before provider dispatch");
434
1151
  this.db.prepare(`INSERT INTO operation_journal(idempotency_key,run_id,kind,status,intent_json,created_at,updated_at)
435
1152
  VALUES(?,?,?,'planned',?,?,?) ON CONFLICT(idempotency_key) DO NOTHING`).run(input.idempotencyKey, input.runId, input.kind, json(input.intent), at, at);
436
1153
  const operation = this.operation(input.idempotencyKey);
@@ -441,6 +1158,8 @@ export class WorkflowDb {
441
1158
  }
442
1159
  updateOperation(idempotencyKey, status, details = {}, now = new Date()) {
443
1160
  const prior = this.operation(idempotencyKey);
1161
+ if (prior?.kind === "provider-dispatch" && status === "in_progress" && this.unresolvedPreparationProcesses(prior.runId).length)
1162
+ throw new Error("Readiness cleanup must be reconciled before provider dispatch");
444
1163
  if (!prior)
445
1164
  throw new Error(`operation not found: ${idempotencyKey}`);
446
1165
  const allowed = {
@@ -458,6 +1177,12 @@ export class WorkflowDb {
458
1177
  this.db.transaction(() => {
459
1178
  this.db.prepare("UPDATE operation_journal SET status=?,result_json=?,external_id=?,error=?,updated_at=? WHERE idempotency_key=?")
460
1179
  .run(status, details.result === undefined ? null : json(details.result), details.externalId ?? null, details.error ?? null, at, idempotencyKey);
1180
+ if (status === "confirmed" && ["ticket-complete", "synthetic-completion"].includes(prior.kind)) {
1181
+ const workId = prior.intent.workId ?? prior.intent.ticket ?? prior.intent.ticketId;
1182
+ const admission = workId && this.admittedWork(prior.runId, workId);
1183
+ if (admission)
1184
+ this.qaPreparationStore().metric(prior.runId, workId, admission.requirementsDigest, `metric:completed:${prior.runId}:${workId}`, "completed", { status: this.getRun(prior.runId)?.state?.qaEnabled === false ? "disabled" : undefined, at });
1185
+ }
461
1186
  this.appendContinuityEvent({ runId: prior.runId, role: "host", kind: "operation_receipt", payload: { idempotencyKey, kind: prior.kind, status, externalId: details.externalId, error: details.error }, authoritativeStateRevision: this.continuityHead(prior.runId, "run")?.authoritativeStateRevision ?? 0 }, now);
462
1187
  })();
463
1188
  return this.operation(idempotencyKey);
@@ -491,9 +1216,15 @@ export class WorkflowDb {
491
1216
  throw new Error(`${kind} evidence exceeds the durable ${maximum}-byte item limit`);
492
1217
  const digest = createHash("sha256").update(bytes).digest("hex");
493
1218
  this.db.prepare("INSERT OR IGNORE INTO content_refs(digest,kind,content,created_at) VALUES(?,?,?,?)").run(digest, kind, bytes, now.toISOString());
1219
+ const graphAccess = currentGraphDerivedAccess();
1220
+ if (graphAccess && (kind === "qa" || kind === "handoff"))
1221
+ registerGraphDerived(this.db, bytes, graphAccess);
494
1222
  return digest;
495
1223
  }
1224
+ registerGraphEvidence(value, access) { registerGraphDerived(this.db, value, access); }
496
1225
  getEvidence(digest) {
1226
+ if (!graphDerivedAllowed(this.db, this.projectDir, digest))
1227
+ return undefined;
497
1228
  const row = this.db.prepare("SELECT content FROM content_refs WHERE digest=?").get(digest);
498
1229
  return row?.content;
499
1230
  }
@@ -548,6 +1279,11 @@ export class WorkflowDb {
548
1279
  const next = { ...prior, ...patch, updatedAt: now.toISOString() };
549
1280
  this.db.prepare("UPDATE qa_review_attempts SET status=?,report_digest=?,record_json=?,updated_at=? WHERE attempt_id=?")
550
1281
  .run(next.status, next.reportDigest ?? null, json({ findingIds: next.findingIds, namespacedFindingIds: next.namespacedFindingIds, detail: next.detail }), next.updatedAt, attemptId);
1282
+ const admission = this.admittedWork(next.runId, next.ticketId);
1283
+ const turns = this.db.prepare("SELECT receipt_json FROM qa_turns WHERE run_id=? AND ticket_id=? AND review_number=? AND status='completed'").all(next.runId, next.ticketId, next.reviewNumber);
1284
+ const terminalBlock = turns.some(row => { const receipt = parseJson(row.receipt_json); const response = receipt.cleanedResponseDigest && this.getEvidence(receipt.cleanedResponseDigest); return receipt.terminalEventObserved && response && /STEP_STATUS:\s*(blocked|needs_input)\b/.test(response.toString()); });
1285
+ if (admission && (next.status === "passed" || next.status === "failed" || terminalBlock))
1286
+ this.qaPreparationStore().metric(next.runId, next.ticketId, admission.requirementsDigest, `metric:review:${attemptId}`, "substantive-review", { status: next.status === "passed" ? "passed" : next.status === "failed" ? "failed" : "blocked", at: now.toISOString(), evidenceRefs: next.reportDigest ? [next.reportDigest] : [] });
551
1287
  return next;
552
1288
  }
553
1289
  qaReviewAttempts(runId, ticketId) {
@@ -603,6 +1339,8 @@ export class WorkflowDb {
603
1339
  .run(record.invocationId, record.runId, record.ticketId, qaDigest("handback-invocation", intent), json(intent), record.status, json(record), record.startedAt, record.completedAt ?? new Date().toISOString());
604
1340
  }
605
1341
  recordQaDeliveryTurn(turn) {
1342
+ if (turn.version === 4)
1343
+ ensureQaGraphJournalGuards(this.db);
606
1344
  const prior = this.qaDeliveryTurns(turn.operationId).find(t => t.turnRecordId === turn.turnRecordId);
607
1345
  if (prior) {
608
1346
  if (prior.status !== "intended") {
@@ -642,7 +1380,8 @@ export class WorkflowDb {
642
1380
  if (linked.size !== recoveries.length)
643
1381
  throw new Error("QA remediation budget requires legacy attempt reconciliation");
644
1382
  const authorized = new Set(this.db.prepare("SELECT consumed_by FROM qa_remediation_authorizations WHERE run_id=? AND ticket_id=? AND consumed_by IS NOT NULL").all(runId, ticketId).map(r => r.consumed_by));
645
- return attempts.filter(a => !authorized.has(a.attemptId)).length;
1383
+ const followups = this.operations(runId).filter(operation => operation.kind === "build-assignment" && operation.intent.ticketId === ticketId && operation.intent.managerFollowup);
1384
+ return attempts.filter(a => !authorized.has(a.attemptId)).length + followups.filter(operation => !authorized.has(operation.idempotencyKey)).length;
646
1385
  }
647
1386
  authorizeQaRemediation(runId, ticketId, reviewAttemptId, reason) {
648
1387
  const authorizationId = randomUUID();
@@ -893,6 +1632,12 @@ export class WorkflowDb {
893
1632
  return this.db.transaction(() => {
894
1633
  const predecessors = this.unresolvedQaReports(input.runId, input.ticketId);
895
1634
  const current = this.recordQaReport(input, findingIds, now);
1635
+ const admission = this.admittedWork(input.runId, input.ticketId), preparation = this.qaPreparationStore();
1636
+ if (admission && preparation.policy(input.runId)) {
1637
+ const evidence = preparation.putArtifact("finding-classification-input", { report: input, contractHead: preparation.head(input.runId, input.ticketId, admission.requirementsDigest), evidenceDisposition: "Cause remains unknown until supported assessment" });
1638
+ for (const findingId of findingIds)
1639
+ preparation.metric(input.runId, input.ticketId, admission.requirementsDigest, `metric:finding-unknown:${current.reportOccurrenceId}:${findingId}`, "finding-classification", { findingId, classifier: "host-evidence-boundary", cause: "unknown", confidence: "unknown", evidenceRefs: [evidence], at: now.toISOString() });
1640
+ }
896
1641
  for (const predecessor of predecessors) {
897
1642
  if (predecessor.reportOccurrenceId === current.reportOccurrenceId)
898
1643
  continue;
@@ -964,13 +1709,38 @@ export class WorkflowDb {
964
1709
  for (const report of this.unresolvedQaReports(runId, ticketId))
965
1710
  this.setQaReportDisposition(report.reportOccurrenceId, "verified-fixed", "subsequent bound QA review passed", now);
966
1711
  }
967
- commitQaWaiver(runId, ticketId, expectedRevision, reason, now = new Date()) {
1712
+ authorizeContractQaWaiver(runId, workId, decisionId) {
1713
+ this.assertInstructionOwner(runId);
1714
+ const decision = this.humanDecision(decisionId);
1715
+ if (!decision || decision.runId !== runId || decision.status !== "answered" || decision.selectedChoiceId !== "yes" || decision.interruptionId !== `ticket:${workId}` || !this.humanDecisionKey(decisionId)?.startsWith(`${runId}:qa-nonconvergence:`) || !decision.prompt.includes("QA waiver confirmation"))
1716
+ throw new Error("Whole-work waiver requires the actual answered operator confirmation");
1717
+ const admission = this.assertAdmittedWork(runId, workId), store = this.qaPreparationStore(), head = store.head(runId, workId, admission.requirementsDigest), qa = this.qaTicketHead(runId, workId);
1718
+ if (head.state !== "ready" || !head.digest)
1719
+ throw new Error("Whole-work waiver requires a current ready contract");
1720
+ return store.putArtifact("waiver-authorization", { decisionId, runId, workId, admissionDigest: admission.requirementsDigest, contractDigest: head.digest, qaRevision: qa.revision, sourceDigest: qa.sourceStateDigest, reviewBasisDigest: qa.reviewBasisDigest, authorizedAt: decision.answeredAt });
1721
+ }
1722
+ commitQaWaiver(runId, ticketId, expectedRevision, reason, now = new Date(), authorizationRef) {
968
1723
  return this.db.transaction(() => {
969
1724
  const head = this.qaTicketHead(runId, ticketId);
970
1725
  if (head.revision !== expectedRevision)
971
1726
  throw new Error(`QA waiver raced for ${runId}/${ticketId}`);
1727
+ const store = this.qaPreparationStore();
1728
+ if (store.policy(runId)?.mode === "enforce") {
1729
+ const admission = this.assertAdmittedWork(runId, ticketId), contractHead = store.head(runId, ticketId, admission.requirementsDigest);
1730
+ if (contractHead.state !== "ready" || !contractHead.digest || !reason.trim())
1731
+ throw new Error("Whole-work waiver requires exact contract scope and operator reason");
1732
+ const authority = authorizationRef ? store.artifact(authorizationRef, "waiver-authorization") : undefined;
1733
+ const decision = authority && this.humanDecision(authority.decisionId);
1734
+ if (!authority || authority.runId !== runId || authority.workId !== ticketId || authority.admissionDigest !== admission.requirementsDigest || authority.contractDigest !== contractHead.digest || authority.qaRevision !== expectedRevision || authority.sourceDigest !== head.sourceStateDigest || authority.reviewBasisDigest !== head.reviewBasisDigest || decision?.status !== "answered" || decision.selectedChoiceId !== "yes")
1735
+ throw new Error("Missing or stale whole-work waiver authorization");
1736
+ const contract = store.contract(contractHead.digest), review = this.qaReviewAttempts(runId, ticketId).at(-1);
1737
+ store.event(runId, ticketId, `whole-work-waiver:${runId}:${ticketId}:${head.revision}`, "whole-work-waiver", { version: 1, runId, workId: ticketId, admissionDigest: admission.requirementsDigest, contractDigest: contract.contentDigest, revision: contract.revision, sourceDigest: review?.sourceDigest ?? null, reviewBasisDigest: head.reviewBasisDigest ?? null, mandatoryCheckIds: contract.checks.filter(check => check.obligation === "mandatory").map(check => check.id), unresolvedReports: this.unresolvedQaReports(runId, ticketId).map(report => report.reportOccurrenceId), reason, actor: "operator", authorizationRef, decisionId: authority.decisionId, mechanism: "existing-qa-nonconvergence-decision", at: now.toISOString(), completion: "waived" });
1738
+ }
972
1739
  for (const report of this.unresolvedQaReports(runId, ticketId))
973
1740
  this.setQaReportDisposition(report.reportOccurrenceId, "waived", reason, now);
1741
+ const admission = this.admittedWork(runId, ticketId);
1742
+ if (admission)
1743
+ store.metric(runId, ticketId, admission.requirementsDigest, `metric:waiver:${runId}:${ticketId}:${head.revision}`, "waived", { at: now.toISOString() });
974
1744
  return this.transitionQa(runId, ticketId, head.revision, { type: "waived" }, now);
975
1745
  })();
976
1746
  }
@@ -995,6 +1765,11 @@ export class WorkflowDb {
995
1765
  return this.db.transaction(() => {
996
1766
  this.recordBuilderRemediationReceipt(receipt);
997
1767
  this.markQaReportsRecheckRequired(receipt.runId, receipt.ticketId, "Builder remediation received; QA recheck required", now);
1768
+ const admission = this.admittedWork(receipt.runId, receipt.ticketId);
1769
+ const remediation = this.qaRemediationAttempt(receipt.operationId);
1770
+ const review = remediation ? this.qaReviewAttempt(remediation.reviewAttemptId) : undefined;
1771
+ if (admission)
1772
+ this.qaPreparationStore().metric(receipt.runId, receipt.ticketId, admission.requirementsDigest, `metric:remediation:${receipt.operationId}`, "remediation", { phase: "remediation", findingIds: review?.namespacedFindingIds ?? [], at: now.toISOString() });
998
1773
  return this.transitionQa(receipt.runId, receipt.ticketId, expectedRevision, { type: "remediation-received" }, now);
999
1774
  })();
1000
1775
  }
@@ -1016,7 +1791,60 @@ export class WorkflowDb {
1016
1791
  return this.transitionQa(remediation.runId, remediation.ticketId, head.revision, { type: input.outcome === "uncertain" ? "remediation-uncertain" : "remediation-failed" }, now);
1017
1792
  })();
1018
1793
  }
1794
+ assertContractCertificate(input) {
1795
+ const store = this.qaPreparationStore();
1796
+ if (store.policy(input.runId)?.mode !== "enforce")
1797
+ return;
1798
+ const binding = input.contractCoverage;
1799
+ if (!binding || binding.version !== 1)
1800
+ throw new Error("Enforcing pass requires tagged contract coverage authority");
1801
+ const admission = this.assertAdmittedWork(input.runId, input.ticketId);
1802
+ const head = store.head(input.runId, input.ticketId, admission.requirementsDigest);
1803
+ if (head.state !== "ready" || head.digest !== binding.contractDigest)
1804
+ throw new Error("Pass contract is stale or incomplete");
1805
+ const contract = store.contract(binding.contractDigest);
1806
+ if (contract.revision !== binding.revision)
1807
+ throw new Error("Pass revision mismatch");
1808
+ const basisRow = this.db.prepare("SELECT basis_json FROM qa_review_bases WHERE digest=? AND run_id=? AND ticket_id=?").get(input.reviewBasisDigest, input.runId, input.ticketId);
1809
+ if (!basisRow)
1810
+ throw new Error("Pass lacks immutable input basis");
1811
+ const basis = parseJson(basisRow.basis_json);
1812
+ const definition = this.workDefinitions(input.runId).find(work => work.workId === input.ticketId)?.definition;
1813
+ if (!definition || basis.ticketDigest !== qaDigest("ticket", definition))
1814
+ throw new Error("Pass ticket basis differs from frozen admitted scope");
1815
+ const { digest, ...fields } = basis;
1816
+ if (digest !== qaDigest("review-basis-fields", fields) || basis.contractBinding?.digest !== contract.contentDigest || basis.contractBinding.admissionDigest !== admission.requirementsDigest)
1817
+ throw new Error("Pass basis/contract binding mismatch");
1818
+ const configuredMake = this.getRun(input.runId)?.state?.qa?.settings?.make;
1819
+ if (configuredMake && resolveEffectiveQaConfiguration(this.projectDir, { make: configuredMake }).digest !== basis.contractBinding.commonConfigDigest)
1820
+ throw new Error("Effective QA configuration changed before pass authority; reconcile contract");
1821
+ const attempt = this.qaReviewAttempt(binding.attemptId);
1822
+ if (!attempt || attempt.runId !== input.runId || attempt.ticketId !== input.ticketId || attempt.sourceDigest !== input.sourceStateDigest || attempt.reviewNumber !== this.qaTicketHead(input.runId, input.ticketId).reviewNumber)
1823
+ throw new Error("Coverage review attempt is stale or foreign");
1824
+ const coverage = store.artifact(binding.coverageDigest, "final-coverage");
1825
+ for (const check of contract.checks)
1826
+ for (const method of check.verification)
1827
+ if (method.equivalentAuthorityId && !store.equivalentResolver()(method.equivalentAuthorityId, contract, check, method, input.sourceStateDigest))
1828
+ throw new Error("Equivalent verification authority is stale or foreign");
1829
+ const errors = validateContractCoverage(contract, coverage, { phase: "qa", sourceDigest: input.sourceStateDigest, inputBasisDigest: input.reviewBasisDigest, attemptId: binding.attemptId, sessionId: binding.sessionId });
1830
+ if (errors.length)
1831
+ throw new Error(errors.join("; "));
1832
+ const initialTurns = this.db.prepare("SELECT intent_json FROM qa_turns WHERE run_id=? AND ticket_id=? AND review_number=? AND source_state_digest=? AND review_basis_digest=?").all(input.runId, input.ticketId, attempt.reviewNumber, input.sourceStateDigest, input.reviewBasisDigest);
1833
+ const initial = initialTurns.map(row => parseJson(row.intent_json)).find(turn => turn.slot === "initial");
1834
+ if (!initial || initial.providerSession.sessionId !== binding.sessionId)
1835
+ throw new Error("Coverage lacks the actual fresh review dispatch identity");
1836
+ const dispatchedInstruction = this.getEvidence(initial.instructionDigest)?.toString();
1837
+ const envelope = `\nImmutable input-basis digest: ${basis.digest}. Coverage must bind this inputBasisDigest.`;
1838
+ if (basis.contractBinding.transportVersion !== 1 || !dispatchedInstruction?.endsWith(envelope) || qaDigest("instruction", dispatchedInstruction.slice(0, -envelope.length)) !== basis.instructionDigest)
1839
+ throw new Error("Actual review dispatch differs from frozen base instruction and tagged input-basis envelope");
1840
+ const receipts = this.db.prepare("SELECT receipt_json FROM qa_turns WHERE run_id=? AND ticket_id=? AND source_state_digest=? AND review_basis_digest=? AND status='completed'").all(input.runId, input.ticketId, input.sourceStateDigest, input.reviewBasisDigest);
1841
+ const receipt = receipts.map(row => parseJson(row.receipt_json)).find(row => qaDigest("turn-receipt", row) === input.turnReceiptDigest && row.terminalEventObserved);
1842
+ const response = receipt?.cleanedResponseDigest ? this.getEvidence(receipt.cleanedResponseDigest) : undefined;
1843
+ if (!receipt || !response || canonicalJson(parseContractCoverage(response.toString())) !== canonicalJson(coverage))
1844
+ throw new Error("Coverage is not bound to the retained terminal review receipt");
1845
+ }
1019
1846
  issueQaPassCertificate(input, now = new Date()) {
1847
+ this.assertContractCertificate(input);
1020
1848
  if (this.unresolvedQaReports(input.runId, input.ticketId).length)
1021
1849
  throw new Error("cannot issue QA pass certificate while reports remain unresolved");
1022
1850
  const certificate = { version: 2, certificateId: qaDigest("pass-certificate", { ...input, issuedAt: now.toISOString() }), ...input, unresolvedReportCount: 0, issuedAt: now.toISOString() };
@@ -1041,6 +1869,7 @@ export class WorkflowDb {
1041
1869
  || attempt.sourceDigest !== input.sourceStateDigest || attempt.status !== "started") {
1042
1870
  throw new Error(`QA pass attempt binding mismatch: ${attemptId}`);
1043
1871
  }
1872
+ this.assertContractCertificate(input);
1044
1873
  this.finishQaReviewAttempt(attemptId, { status: "passed", detail }, now);
1045
1874
  return this.commitQaPass(input, expectedRevision, now);
1046
1875
  })();
@@ -1052,6 +1881,7 @@ export class WorkflowDb {
1052
1881
  throw new Error("QA pass certificate is missing or scoped to another run/ticket");
1053
1882
  if (row.consumed_at)
1054
1883
  throw new Error("QA pass certificate has already been consumed");
1884
+ this.assertContractCertificate(parseJson(row.certificate_json));
1055
1885
  if (this.unresolvedQaReports(runId, ticketId).length)
1056
1886
  throw new Error("QA pass certificate cannot be consumed with unresolved reports");
1057
1887
  const at = now.toISOString();
@@ -1062,9 +1892,9 @@ export class WorkflowDb {
1062
1892
  return certificate;
1063
1893
  })();
1064
1894
  }
1065
- invalidateUnconsumedQaPassCertificates(runId, ticketId, reason, now = new Date()) {
1895
+ invalidateUnconsumedQaPassCertificates(runId, ticketId, reason, now = new Date(), issuedBefore) {
1066
1896
  const at = now.toISOString();
1067
- this.db.prepare("UPDATE qa_pass_certificates SET consumed_at=?,consumed_by=? WHERE run_id=? AND ticket_id=? AND consumed_at IS NULL").run(at, `invalidated:${reason}`, runId, ticketId);
1897
+ this.db.prepare("UPDATE qa_pass_certificates SET consumed_at=?,consumed_by=? WHERE run_id=? AND ticket_id=? AND consumed_at IS NULL AND (? IS NULL OR issued_at<=?)").run(at, `invalidated:${reason}`, runId, ticketId, issuedBefore ?? null, issuedBefore ?? null);
1068
1898
  }
1069
1899
  invalidateQaPassBeforeFinalization(input, now = new Date()) {
1070
1900
  return this.db.transaction(() => {
@@ -1080,6 +1910,7 @@ export class WorkflowDb {
1080
1910
  }
1081
1911
  beginQaFinalization(input, now = new Date()) {
1082
1912
  return this.db.transaction(() => {
1913
+ this.assertFinalizationControls(input.runId, input.ticketId);
1083
1914
  const head = this.qaTicketHead(input.runId, input.ticketId);
1084
1915
  if (head.revision !== input.expectedRevision || head.state !== "passed")
1085
1916
  throw new Error(`QA finalization start raced for ${input.runId}/${input.ticketId}`);
@@ -1156,10 +1987,14 @@ export class WorkflowDb {
1156
1987
  }
1157
1988
  completeQaFinalization(runId, ticketId, expectedRevision, receipt, now = new Date()) {
1158
1989
  return this.db.transaction(() => {
1990
+ this.assertFinalizationControls(runId, ticketId);
1159
1991
  const head = this.qaTicketHead(runId, ticketId);
1160
1992
  if (head.revision !== expectedRevision || head.state !== "finalizing")
1161
1993
  throw new Error(`QA finalization completion raced for ${runId}/${ticketId}`);
1162
1994
  this.finishPendingQaFinalizationSteps(runId, ticketId, receipt, now);
1995
+ const admission = this.admittedWork(runId, ticketId);
1996
+ if (admission)
1997
+ this.qaPreparationStore().metric(runId, ticketId, admission.requirementsDigest, `metric:completed:${runId}:${ticketId}`, "completed", { at: now.toISOString() });
1163
1998
  return this.transitionQa(runId, ticketId, head.revision, { type: "completed" }, now);
1164
1999
  })();
1165
2000
  }
@@ -1475,16 +2310,36 @@ export class WorkflowDb {
1475
2310
  const pid = process.pid;
1476
2311
  const processStart = processStartIdentity();
1477
2312
  return this.db.transaction(() => {
2313
+ const admission = this.buildAdmission();
2314
+ if (admission) {
2315
+ const local = localBuildAuthority(this.projectDir);
2316
+ if (!local || local.token !== admission.token || local.runId !== runId) {
2317
+ const state = classifyProcess(admission.pid, admission.processStart, admission.host);
2318
+ if (state.state !== "dead")
2319
+ throw new Error(`Build ${admission.runId} admission is ${state.state}: ${state.reason}`);
2320
+ if (this.db.prepare("SELECT 1 FROM build_launches WHERE run_id=? AND state IN ('reserved','dispatching')").get(admission.runId))
2321
+ throw new Error("Unresolved build launch requires reconciliation");
2322
+ }
2323
+ else
2324
+ assertAdmission(this.db, local);
2325
+ }
1478
2326
  const current = this.currentLease();
1479
2327
  if (current && leaseVerifiedLive(current, now, staleMs))
1480
2328
  throw new Error(`project workflow lease is held by ${current.owner} for run ${current.runId}`);
1481
- const generation = (current?.generation ?? 0) + 1;
2329
+ const sequence = this.db.prepare("SELECT generation FROM workflow_lease_sequence WHERE singleton=1").get();
2330
+ const generation = Math.max(sequence.generation, current?.generation ?? 0) + 1;
2331
+ this.db.prepare("UPDATE workflow_lease_sequence SET generation=? WHERE singleton=1").run(generation);
1482
2332
  this.db.prepare(`INSERT INTO project_lease(singleton,owner,generation,pid,host,process_start,heartbeat_at,run_id)
1483
2333
  VALUES(1,?,?,?,?,?,?,?) ON CONFLICT(singleton) DO UPDATE SET owner=excluded.owner,generation=excluded.generation,pid=excluded.pid,host=excluded.host,process_start=excluded.process_start,heartbeat_at=excluded.heartbeat_at,run_id=excluded.run_id`)
1484
2334
  .run(owner, generation, pid, host, processStart, at, runId);
1485
2335
  this.db.prepare("UPDATE workflow_runs SET lease_generation=? WHERE run_id=?").run(generation, runId);
1486
2336
  this.insertEvent(runId, current ? "lease_takeover" : "lease_acquired", "lease", { owner, generation, previous: current?.owner }, at);
1487
- return { owner, generation, pid, host, processStart, heartbeatAt: at, runId };
2337
+ const lease = { owner, generation, pid, host, processStart, heartbeatAt: at, runId };
2338
+ this.writerLease = lease;
2339
+ rememberOriginalLease(this.projectDir, lease);
2340
+ if (this.getRun(runId)?.kind === "build")
2341
+ this.db.prepare("INSERT OR IGNORE INTO build_runtime_runs VALUES(?)").run(runId);
2342
+ return lease;
1488
2343
  }).immediate();
1489
2344
  }
1490
2345
  heartbeatLease(lease, now = new Date()) {
@@ -1497,6 +2352,8 @@ export class WorkflowDb {
1497
2352
  releaseLease(lease, now = new Date()) {
1498
2353
  this.db.transaction(() => {
1499
2354
  const result = this.db.prepare("DELETE FROM project_lease WHERE singleton=1 AND owner=? AND generation=?").run(lease.owner, lease.generation);
2355
+ if (result.changes === 1)
2356
+ forgetOriginalLease(this.projectDir, lease);
1500
2357
  if (result.changes === 1)
1501
2358
  this.insertEvent(lease.runId, "lease_released", "lease", { generation: lease.generation }, now.toISOString());
1502
2359
  })();
@@ -1741,25 +2598,12 @@ function sanitizeContinuityValue(value, depth = 0) {
1741
2598
  function leaseVerifiedLive(lease, now, staleMs) {
1742
2599
  void now;
1743
2600
  void staleMs;
1744
- // Heartbeat expiry is not proof of process death. Remote or inaccessible
1745
- // identities require operator reconciliation; only proven-dead owners move.
1746
- if (lease.host !== hostname())
1747
- return true;
1748
- try {
1749
- process.kill(lease.pid, 0);
1750
- }
1751
- catch (error) {
1752
- return error.code !== "ESRCH";
1753
- }
1754
- const actual = processStartIdentity(lease.pid);
1755
- if (actual === "unavailable" || lease.processStart === "unavailable")
1756
- return true;
1757
- return actual === lease.processStart;
2601
+ return classifyProcess(lease.pid, lease.processStart, lease.host).state !== "dead";
1758
2602
  }
1759
2603
  function ensureRecoveryGitignore(projectDir) {
1760
2604
  const localExclude = join(projectDir, ".git", "info", "exclude");
1761
2605
  const path = existsSync(localExclude) ? localExclude : join(projectDir, ".gitignore");
1762
- const entries = [WORKFLOW_DB_FILE, `${WORKFLOW_DB_FILE}-wal`, `${WORKFLOW_DB_FILE}-shm`, ".rafi/cache/handoffs/"];
2606
+ const entries = [WORKFLOW_DB_FILE, `${WORKFLOW_DB_FILE}-wal`, `${WORKFLOW_DB_FILE}-shm`, ".rafi/cache/handoffs/", ".rafi/backups/work-admission-v4/"];
1763
2607
  const existing = existsSync(path) ? readFileSync(path, "utf8") : "";
1764
2608
  const missing = entries.filter((entry) => !existing.split(/\r?\n/).includes(entry));
1765
2609
  if (missing.length)