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
@@ -189,6 +189,7 @@ export class RoleStatusAdapter {
189
189
  restoreNativeCompactions(compactions) { this.adapter.restoreNativeCompactions?.(compactions); }
190
190
  contextUsageAfterNativeCompaction(compaction) { return this.adapter.contextUsageAfterNativeCompaction?.(compaction) ?? Promise.resolve(undefined); }
191
191
  contextUsage() { return this.adapter.contextUsage?.() ?? Promise.resolve(undefined); }
192
+ graphRuntimeSettings() { return this.adapter.graphRuntimeSettings?.(); }
192
193
  sessionUsage() { return this.adapter.sessionUsage?.() ?? Promise.resolve(undefined); }
193
194
  switchSettings(settings) { return this.adapter.switchSettings?.(settings) ?? Promise.resolve({ ok: false, error: "settings switch unavailable" }); }
194
195
  events() { return this.adapter.events(); }
@@ -7,6 +7,12 @@ export declare function superviseStart(projectDir: string, runId: string, policy
7
7
  steps?: string;
8
8
  stacks?: string;
9
9
  detach?: boolean;
10
+ launchToken?: string;
11
+ startArgs?: string[];
10
12
  }): Promise<boolean>;
11
13
  /** Deliberately narrow: established work requires the normal recovery flow. */
12
14
  export declare function canRestartPreparation(db: WorkflowDb, runId: string): boolean;
15
+ /** Cleanup cannot hide the original failure or erase an uncertain launch. */
16
+ export declare function finishStartAdmission(projectDir: string, runId: string): void;
17
+ /** Re-exec inherits run selection, but transfers only the newly reserved capability. */
18
+ export declare function normalizeNestedLaunchArguments(args: readonly string[], token: string): string[];
@@ -1,9 +1,10 @@
1
+ import { maintainBuildAdmission, stopBuildAdmissionHeartbeat, launchDigest, localBuildAuthority } from "./buildAdmission.js";
2
+ import { fileURLToPath } from "node:url";
1
3
  import { execFileSync, spawn } from "node:child_process";
2
4
  import { closeSync, mkdirSync, openSync } from "node:fs";
3
5
  import { join } from "node:path";
4
6
  import { DurableSupervisor } from "./supervisor.js";
5
7
  import { WorkflowDb } from "./workflowDb.js";
6
- import { processStartIdentity } from "./processIdentity.js";
7
8
  import { cancelOwnedRuntimeProbes } from "./runtimeReadiness.js";
8
9
  /** Worker IPC is created by the parent; an environment flag alone cannot bypass supervision. */
9
10
  export function supervisedRunId() {
@@ -11,6 +12,28 @@ export function supervisedRunId() {
11
12
  }
12
13
  /** Both ordinary and branch starts enter through this process boundary. */
13
14
  export async function superviseStart(projectDir, runId, policy, selection) {
15
+ const preparation = new WorkflowDb(projectDir);
16
+ const invocationDigest = launchDigest(selection?.startArgs ?? ["start", projectDir]);
17
+ try {
18
+ const environmentToken = process.env.RAFI_BUILD_LAUNCH_TOKEN;
19
+ if (environmentToken && selection?.launchToken && environmentToken !== selection.launchToken)
20
+ throw new Error("Conflicting build launch contexts");
21
+ const launchToken = environmentToken ?? selection?.launchToken;
22
+ if (launchToken) {
23
+ preparation.claimBuildLaunch(runId, launchToken, supervisedRunId() ? "worker" : "coordinator", invocationDigest);
24
+ delete process.env.RAFI_BUILD_LAUNCH_TOKEN;
25
+ }
26
+ else
27
+ preparation.acquireBuildAdmission(runId, policy.supervisorEnabled && process.argv.includes("start") ? "coordinator" : "worker");
28
+ const run = preparation.ensureRun(runId, "build");
29
+ policy = preparation.freezeAutonomyPolicy(runId, policy);
30
+ if (selection?.startArgs && !run.state.runId && !run.state.startArgs)
31
+ preparation.transition(runId, { checkpoint: run.checkpoint, state: { ...run.state, startArgs: selection.startArgs }, event: "build_preparation_saved" });
32
+ }
33
+ finally {
34
+ preparation.close();
35
+ }
36
+ maintainBuildAdmission(localBuildAuthority(projectDir));
14
37
  if (supervisedRunId()) {
15
38
  const heartbeat = setInterval(() => { if (process.connected)
16
39
  process.send?.({ kind: "rafi-worker-heartbeat", runId }); }, 5_000);
@@ -60,7 +83,7 @@ export async function superviseStart(projectDir, runId, policy, selection) {
60
83
  if (!policy.supervisorEnabled) {
61
84
  const db = new WorkflowDb(projectDir);
62
85
  try {
63
- db.putSupervisorState(runId, { status: "disabled", generation: 0, workerGeneration: 0, checkpointRestarts: 0, runRestarts: 0 });
86
+ db.putSupervisorState(runId, { generation: 0, workerGeneration: 0, checkpointRestarts: 0, runRestarts: 0, ...db.supervisorState(runId), status: "disabled" });
64
87
  }
65
88
  finally {
66
89
  db.close();
@@ -74,19 +97,55 @@ export async function superviseStart(projectDir, runId, policy, selection) {
74
97
  args.push("--steps", selection.steps);
75
98
  if (!args.some(arg => arg === "--stacks" || arg.startsWith("--stacks=")) && selection?.stacks)
76
99
  args.push("--stacks", selection.stacks);
100
+ const gate = fileURLToPath(new URL(import.meta.url.endsWith(".ts") ? "./buildLaunchGate.ts" : "./buildLaunchGate.js", import.meta.url));
101
+ const gatedArgs = (token) => [...process.execArgv, gate, process.argv[1], projectDir, token, ...normalizeNestedLaunchArguments(args.slice(process.execArgv.length + 1), token)];
77
102
  if (selection?.detach && !process.env.RAFI_DETACHED_SUPERVISOR_RUN) {
78
103
  const directory = join(projectDir, ".rafi", "logs");
79
104
  mkdirSync(directory, { recursive: true });
80
105
  const path = join(directory, `supervisor-${runId}.log`);
81
106
  const fd = openSync(path, "a", 0o600);
107
+ const launchDb = new WorkflowDb(projectDir);
108
+ const authority = localBuildAuthority(projectDir);
109
+ const launch = launchDb.reserveBuildLaunch(authority, "coordinator", invocationDigest, "registered-v2");
110
+ launchDb.dispatchBuildLaunch(authority, launch.token);
82
111
  try {
83
- const parent = spawn(process.execPath, args, { cwd: process.cwd(), detached: true,
84
- env: { ...process.env, RAFI_DETACHED_SUPERVISOR_RUN: runId }, stdio: ["ignore", fd, fd] });
85
- await new Promise((resolve, reject) => { parent.once("spawn", resolve); parent.once("error", reject); });
86
- parent.unref();
112
+ const parent = spawn(process.execPath, gatedArgs(launch.token), { cwd: process.cwd(), detached: true,
113
+ env: { ...process.env, RAFI_DETACHED_SUPERVISOR_RUN: runId, RAFI_BUILD_LAUNCH_TOKEN: launch.token }, stdio: ["ignore", fd, fd, "ipc"] });
114
+ try {
115
+ await new Promise((resolve, reject) => {
116
+ const timer = setTimeout(() => { parent.kill("SIGTERM"); reject(new Error("Detached supervisor startup timed out; use rafi resume to reconcile")); }, 15_000);
117
+ parent.once("message", () => {
118
+ try {
119
+ launchDb.acknowledgeBuildLaunchChild(authority, launch.token, parent.pid);
120
+ parent.send("rafi-launch-ack", error => { clearTimeout(timer); if (error)
121
+ reject(error);
122
+ else
123
+ resolve(); });
124
+ }
125
+ catch (error) {
126
+ clearTimeout(timer);
127
+ parent.kill("SIGTERM");
128
+ reject(error);
129
+ }
130
+ });
131
+ parent.once("error", error => { clearTimeout(timer); if (!parent.pid)
132
+ launchDb.failBuildLaunch(authority, launch.token); reject(error); });
133
+ parent.once("exit", () => { clearTimeout(timer); launchDb.reconcileBuildLaunches(runId, authority); reject(new Error("Detached supervisor exited during startup; use rafi resume")); });
134
+ });
135
+ }
136
+ finally {
137
+ // Startup rejection can be followed by a late exit/error. Remove DB
138
+ // callbacks before the outer finally closes the connection in either case.
139
+ parent.removeAllListeners("exit");
140
+ parent.removeAllListeners("message");
141
+ parent.removeAllListeners("error");
142
+ parent.on("error", () => { });
143
+ parent.unref();
144
+ }
87
145
  console.log(`rafi: launched supervisor for ${runId}; output: ${path}`);
88
146
  }
89
147
  finally {
148
+ launchDb.close();
90
149
  closeSync(fd);
91
150
  }
92
151
  return true;
@@ -95,16 +154,39 @@ export async function superviseStart(projectDir, runId, policy, selection) {
95
154
  db.ensureRun(runId, "build");
96
155
  db.freezeAutonomyPolicy(runId, policy);
97
156
  let exitCode = 2;
98
- const supervisor = new DurableSupervisor({ projectDir, runId, policy,
157
+ let stopRequestSource;
158
+ const supervisor = new DurableSupervisor({ projectDir, runId, policy, admission: localBuildAuthority(projectDir),
99
159
  checkpoint: () => db.getRun(runId)?.checkpoint ?? "preparing",
100
160
  spawnWorker: generation => {
101
- const child = spawn(process.execPath, args, {
102
- cwd: process.cwd(), env: { ...process.env, RAFI_BUILD_WORKER_RUN: runId, RAFI_BUILD_WORKER_GENERATION: String(generation) },
161
+ let authority = localBuildAuthority(projectDir);
162
+ if (db.buildAdmission()?.token !== authority.token) {
163
+ authority = db.reacquireBuildCoordinator(runId);
164
+ }
165
+ const launch = db.reserveBuildLaunch(authority, "worker", invocationDigest, "registered-v2");
166
+ db.dispatchBuildLaunch(authority, launch.token);
167
+ const child = spawn(process.execPath, gatedArgs(launch.token), {
168
+ cwd: process.cwd(), env: { ...process.env, RAFI_BUILD_WORKER_RUN: runId, RAFI_BUILD_WORKER_GENERATION: String(generation), RAFI_BUILD_LAUNCH_TOKEN: launch.token },
103
169
  stdio: ["inherit", "inherit", "inherit", "ipc"], detached: process.platform !== "win32",
104
170
  });
171
+ const startup = setTimeout(() => child.kill("SIGTERM"), 15_000);
172
+ child.on("message", message => {
173
+ if (!message || typeof message !== "object" || !("kind" in message) || message.kind !== "rafi-launch-registered")
174
+ return;
175
+ try {
176
+ db.acknowledgeBuildLaunchChild(authority, launch.token, child.pid);
177
+ child.send("rafi-launch-ack", () => { });
178
+ clearTimeout(startup);
179
+ }
180
+ catch {
181
+ child.kill("SIGTERM");
182
+ }
183
+ });
184
+ child.once("exit", () => clearTimeout(startup));
185
+ child.once("error", () => clearTimeout(startup));
105
186
  let lastHeartbeat = Date.now();
106
187
  let stopping = false;
107
188
  const readinessChildren = new Map();
189
+ const readinessTags = new Map();
108
190
  let killTimer;
109
191
  const stop = () => {
110
192
  if (stopping)
@@ -120,8 +202,10 @@ export async function superviseStart(projectDir, runId, policy, selection) {
120
202
  }
121
203
  catch { /* exited */ }
122
204
  };
123
- const watchdog = setInterval(() => { if (Date.now() - lastHeartbeat > 120_000)
124
- stop(); }, 5_000);
205
+ const watchdog = setInterval(() => { if (Date.now() - lastHeartbeat > 120_000) {
206
+ stopRequestSource = "heartbeat-timeout";
207
+ stop();
208
+ } }, 5_000);
125
209
  watchdog.unref();
126
210
  child.on("message", message => { if (message && typeof message === "object" && "kind" in message && message.kind === "rafi-worker-heartbeat")
127
211
  lastHeartbeat = Date.now(); });
@@ -131,50 +215,78 @@ export async function superviseStart(projectDir, runId, policy, selection) {
131
215
  const value = message;
132
216
  if (!Number.isSafeInteger(value.pid) || Number(value.pid) <= 1)
133
217
  return;
134
- if (value.active === false)
218
+ if (typeof value.tag === "string")
219
+ readinessTags.set(Number(value.pid), value.tag);
220
+ if (value.active === false) {
135
221
  readinessChildren.delete(Number(value.pid));
222
+ readinessTags.delete(Number(value.pid));
223
+ }
136
224
  else if (typeof value.processStart === "string")
137
225
  readinessChildren.set(Number(value.pid), value.processStart);
138
226
  });
139
227
  const result = new Promise(resolve => {
140
- child.once("error", error => { clearInterval(watchdog); resolve({ kind: "failed", detail: error.message }); });
228
+ child.once("error", error => {
229
+ clearInterval(watchdog);
230
+ if (child.pid) {
231
+ stop();
232
+ return;
233
+ } // exit handler owns cleanup and settlement
234
+ db.failBuildLaunch(authority, launch.token);
235
+ resolve({ kind: "failed", detail: error.message });
236
+ });
141
237
  child.once("exit", async (code, signal) => {
142
238
  clearInterval(watchdog);
143
239
  if (killTimer)
144
240
  clearTimeout(killTimer);
145
241
  exitCode = code ?? 2;
146
242
  try {
243
+ const exitState = db.getRun(runId);
244
+ if (exitState)
245
+ db.transitionSupervisor(runId, generation, { checkpoint: exitState.checkpoint, event: "worker_exit", payload: { code, signal, stopRequested: stopping, source: stopRequestSource ?? "unknown", at: new Date().toISOString(), generation } });
246
+ // Durable records are authoritative even when the worker never sent IPC.
247
+ const deadline = Date.now() + 5000;
248
+ const owned = db.readinessProcesses(runId).filter(row => {
249
+ const launchChild = db.buildLaunch(launch.token)?.claimant;
250
+ return launchChild && row.owner === launchChild.token;
251
+ });
252
+ const readinessWasActive = owned.length > 0;
253
+ const readinessCleanup = owned.length ? await db.reconcileReadiness(runId, undefined, deadline) : [];
254
+ const readinessQuiescent = readinessCleanup.length === 0;
255
+ if (!readinessQuiescent) {
256
+ exitCode = 2;
257
+ resolve({ kind: "waiting_for_human" });
258
+ console.error(`rafi: readiness cleanup remains unresolved; use rafi resume (${readinessCleanup.join(", ")})`);
259
+ return;
260
+ }
261
+ const launchOutcome = db.reconcileBuildLaunches(runId, authority);
262
+ if (launchOutcome === "retired" && db.buildLaunch(launch.token)?.state === "failed") {
263
+ resolve({ kind: "crashed", detail: "Worker exited before claiming its launch; authorization retired safely" });
264
+ return;
265
+ }
147
266
  if (signal || stopping) {
148
267
  try {
149
268
  process.kill(process.platform === "win32" ? child.pid : -child.pid, "SIGKILL");
150
269
  }
151
270
  catch { /* owned group exited */ }
152
- const readinessWasActive = readinessChildren.size > 0;
153
- let readinessQuiescent = readinessWasActive;
154
- const readinessCleanup = [];
155
- for (const [pid, identity] of readinessChildren) {
156
- const cleanup = await stopOwnedReadinessGroup(pid, identity);
157
- if (cleanup !== "quiescent") {
158
- readinessQuiescent = false;
159
- readinessCleanup.push(cleanup);
160
- }
161
- }
162
271
  const workerQuiescent = await ownedGroupIsQuiescent(child.pid);
163
272
  const preparationReconciled = canRestartPreparation(db, runId);
164
273
  if (!stopping && readinessQuiescent && workerQuiescent && preparationReconciled) {
165
- db.transition(runId, { status: "paused", checkpoint: "preparation-reconciled", event: "worker_preparation_reconciled", payload: { generation, signal, noImplementationAuthority: true } });
274
+ db.transitionSupervisor(runId, generation, { status: "paused", checkpoint: "preparation-reconciled", event: "worker_preparation_reconciled", payload: { generation, signal, noImplementationAuthority: true } });
166
275
  resolve({ kind: "crashed", detail: "Preparation worker exited before acquiring implementation authority; owned process group is quiescent" });
167
276
  return;
168
277
  }
169
278
  // Process death does not establish that remote side effects stopped.
170
279
  // Never replay an unacknowledged provider action automatically.
171
280
  const uncertain = [...db.unresolvedRoleDispatches(runId, "builder"), ...db.unresolvedRoleDispatches(runId, "qa")];
172
- db.transition(runId, { status: "paused", checkpoint: uncertain.length ? "uncertain-worker-dispatch" : "worker-interrupted", event: "worker_reconciliation_required", payload: { generation, signal, uncertainDispatches: uncertain.map(item => item.idempotencyKey), readinessWasActive, readinessQuiescent, workerQuiescent, preparationReconciled, readinessCleanup, readinessOwnershipVerified: [...readinessChildren.values()].every(value => value !== "unavailable") } });
281
+ db.transitionSupervisor(runId, generation, { status: "paused", checkpoint: uncertain.length ? "uncertain-worker-dispatch" : "worker-interrupted", event: "worker_reconciliation_required", payload: { generation, signal, uncertainDispatches: uncertain.map(item => item.idempotencyKey), readinessWasActive, readinessQuiescent, workerQuiescent, preparationReconciled, readinessCleanup, readinessOwnershipVerified: [...readinessChildren.values()].every(value => value !== "unavailable") } });
173
282
  resolve({ kind: "waiting_for_human" });
174
283
  }
175
284
  else {
176
285
  const state = db.getRun(runId);
177
- if (code === 0 && state?.status !== "completed") {
286
+ if (code === 0 && state?.status === "cancelled") {
287
+ resolve({ kind: "completed" });
288
+ }
289
+ else if (code === 0 && state?.status !== "completed") {
178
290
  exitCode = 2;
179
291
  resolve({ kind: "waiting_for_human" });
180
292
  }
@@ -191,15 +303,16 @@ export async function superviseStart(projectDir, runId, policy, selection) {
191
303
  return { pid: child.pid, result, stop };
192
304
  },
193
305
  });
194
- const stop = () => supervisor.requestStop();
195
- process.once("SIGINT", stop);
196
- process.once("SIGTERM", stop);
306
+ const stopInt = () => { stopRequestSource = "parent-SIGINT"; supervisor.requestStop(); };
307
+ const stopTerm = () => { stopRequestSource = "parent-SIGTERM"; supervisor.requestStop(); };
308
+ process.once("SIGINT", stopInt);
309
+ process.once("SIGTERM", stopTerm);
197
310
  try {
198
311
  await supervisor.run();
199
312
  }
200
313
  finally {
201
- process.off("SIGINT", stop);
202
- process.off("SIGTERM", stop);
314
+ process.off("SIGINT", stopInt);
315
+ process.off("SIGTERM", stopTerm);
203
316
  db.close();
204
317
  }
205
318
  process.exitCode = exitCode;
@@ -208,13 +321,7 @@ export async function superviseStart(projectDir, runId, policy, selection) {
208
321
  /** Deliberately narrow: established work requires the normal recovery flow. */
209
322
  export function canRestartPreparation(db, runId) {
210
323
  const run = db.getRun(runId);
211
- if (!run || !["durable-baseline", "preparing", "preparation-reconciled"].includes(run.checkpoint))
212
- return false;
213
- if (run.state.version !== undefined || db.pendingHumanDecisions(runId).length || db.operations(runId).length || db.handoffs(runId).length)
214
- return false;
215
- if (db.roleMutationLease(runId, "builder") || db.roleMutationLease(runId, "qa"))
216
- return false;
217
- return db.branchResumeSessions(false).length === 0;
324
+ return Boolean(run && ["durable-baseline", "preparing", "preparation-reconciled"].includes(run.checkpoint) && db.preparationEligibility(runId).eligible);
218
325
  }
219
326
  async function ownedGroupIsQuiescent(pid) {
220
327
  if (process.platform === "win32")
@@ -245,32 +352,41 @@ async function ownedGroupIsQuiescent(pid) {
245
352
  } while (Date.now() < until);
246
353
  return false;
247
354
  }
248
- async function stopOwnedReadinessGroup(pid, expectedStart) {
249
- if (process.platform === "win32" || expectedStart === "unavailable")
250
- return "unverified readiness ownership";
251
- try {
252
- process.kill(pid, 0);
253
- const actual = processStartIdentity(pid);
254
- if (actual !== expectedStart)
255
- return `readiness process identity changed: ${expectedStart} -> ${actual}`;
256
- }
257
- catch (error) {
258
- if (error.code !== "ESRCH")
259
- return `readiness liveness unavailable: ${String(error)}`;
260
- }
355
+ /** Cleanup cannot hide the original failure or erase an uncertain launch. */
356
+ export function finishStartAdmission(projectDir, runId) {
357
+ const authority = localBuildAuthority(projectDir);
358
+ if (!authority || authority.runId !== runId)
359
+ return;
360
+ stopBuildAdmissionHeartbeat(authority);
361
+ const db = new WorkflowDb(projectDir);
261
362
  try {
262
- process.kill(-pid, "SIGKILL");
363
+ if (db.buildAdmission()?.token === authority.token) {
364
+ try {
365
+ db.releaseBuildAdmission(authority);
366
+ }
367
+ catch (error) {
368
+ console.error(`rafi: retaining build ownership for reconciliation: ${String(error)}`);
369
+ }
370
+ }
263
371
  }
264
- catch (error) {
265
- if (error.code !== "ESRCH")
266
- return `readiness group cleanup unavailable: ${String(error)}`;
372
+ finally {
373
+ db.close();
267
374
  }
268
- if (await ownedGroupIsQuiescent(pid))
269
- return "quiescent";
270
- let state = "unavailable";
271
- try {
272
- state = execFileSync("ps", ["-o", "pid=,ppid=,pgid=,stat=", "-p", String(pid)], { encoding: "utf8", timeout: 1000, stdio: ["ignore", "pipe", "ignore"] }).trim();
375
+ }
376
+ /** Re-exec inherits run selection, but transfers only the newly reserved capability. */
377
+ export function normalizeNestedLaunchArguments(args, token) {
378
+ const result = [];
379
+ for (let i = 0; i < args.length; i++) {
380
+ if (args[i] === "--launch-token") {
381
+ i++;
382
+ continue;
383
+ }
384
+ if (args[i].startsWith("--launch-token="))
385
+ continue;
386
+ result.push(args[i]);
273
387
  }
274
- catch { /* keep unknown */ }
275
- return `readiness group ${pid} remains active or unverified (${state})`;
388
+ // Ordinary supervised start gets its run from the acknowledged IPC gate.
389
+ if (result.some(arg => arg === "--preparation-run" || arg === "--recover-run" || arg.startsWith("--preparation-run=") || arg.startsWith("--recover-run=")))
390
+ result.push("--launch-token", token);
391
+ return result;
276
392
  }
@@ -1,3 +1,4 @@
1
+ import type { BuildAdmission } from "./buildAdmission.js";
1
2
  import type { ResolvedAutonomyPolicy, SupervisorState } from "rafi-spec";
2
3
  export type WorkerOutcome = {
3
4
  kind: "completed";
@@ -21,6 +22,7 @@ export interface DurableSupervisorOptions {
21
22
  projectDir: string;
22
23
  runId: string;
23
24
  policy: ResolvedAutonomyPolicy;
25
+ admission?: BuildAdmission;
24
26
  spawnWorker: (generation: number) => Promise<SupervisorWorkerHandle> | SupervisorWorkerHandle;
25
27
  checkpoint: () => string;
26
28
  sleep?: (milliseconds: number) => Promise<void>;
@@ -25,7 +25,12 @@ export class DurableSupervisor {
25
25
  db.close();
26
26
  return { kind: "stopped" };
27
27
  }
28
+ let ownedAdmission;
28
29
  try {
30
+ if (this.options.admission)
31
+ db.assertBuildAdmission(this.options.admission);
32
+ else
33
+ ownedAdmission = db.acquireBuildAdmission(this.options.runId, "coordinator");
29
34
  state = db.atomic(() => {
30
35
  for (const other of db.runningSupervisors()) {
31
36
  if (other.runId !== this.options.runId && other.state.pid && processLooksLive(other.state.pid, other.state.processStart))
@@ -43,7 +48,13 @@ export class DurableSupervisor {
43
48
  });
44
49
  }
45
50
  catch (error) {
46
- db.close();
51
+ try {
52
+ if (ownedAdmission)
53
+ db.releaseBuildAdmission(ownedAdmission);
54
+ }
55
+ finally {
56
+ db.close();
57
+ }
47
58
  throw error;
48
59
  }
49
60
  const saveState = () => db.atomic(() => {
@@ -88,6 +99,13 @@ export class DurableSupervisor {
88
99
  saveState();
89
100
  return outcome;
90
101
  }
102
+ const eligibility = db.preparationEligibility(this.options.runId);
103
+ if (!eligibility.eligible) {
104
+ state = { ...state, status: "waiting_for_human" };
105
+ saveState();
106
+ db.transitionSupervisor(this.options.runId, state.workerGeneration, { status: "paused", checkpoint: "worker-reconciliation-required", event: "worker_restart_withheld", payload: { reasons: eligibility.reasons } });
107
+ return { kind: "waiting_for_human" };
108
+ }
91
109
  const checkpointLimit = this.options.policy.limits.workerRestartsPerCheckpoint;
92
110
  const runLimit = this.options.policy.limits.workerRestartsPerRun;
93
111
  if (state.checkpointRestarts >= checkpointLimit || state.runRestarts >= runLimit) {
@@ -105,7 +123,13 @@ export class DurableSupervisor {
105
123
  }
106
124
  finally {
107
125
  clearInterval(heartbeat);
108
- db.close();
126
+ try {
127
+ if (ownedAdmission && db.buildAdmission()?.token === ownedAdmission.token)
128
+ db.releaseBuildAdmission(ownedAdmission);
129
+ }
130
+ finally {
131
+ db.close();
132
+ }
109
133
  }
110
134
  }
111
135
  }
@@ -0,0 +1,21 @@
1
+ /** Small, dependency-free terminal styling and display sanitization helpers. */
2
+ export type TerminalColorRole = "accent" | "success" | "warning" | "error" | "info";
3
+ export interface TerminalStyleStream {
4
+ readonly isTTY?: boolean;
5
+ }
6
+ export interface TerminalStyleContext {
7
+ readonly stream?: TerminalStyleStream;
8
+ readonly env?: NodeJS.ProcessEnv;
9
+ readonly platform?: NodeJS.Platform;
10
+ /** Declared data output is always plain, including FORCE_COLOR sessions. */
11
+ readonly machineReadable?: boolean;
12
+ }
13
+ export declare function terminalColorEnabled(context?: TerminalStyleContext): boolean;
14
+ export declare function styleTerminalText(role: TerminalColorRole, text: string, context?: TerminalStyleContext): string;
15
+ export declare function accent(text: string, context?: TerminalStyleContext): string;
16
+ export declare function success(text: string, context?: TerminalStyleContext): string;
17
+ export declare function warning(text: string, context?: TerminalStyleContext): string;
18
+ export declare function error(text: string, context?: TerminalStyleContext): string;
19
+ export declare function info(text: string, context?: TerminalStyleContext): string;
20
+ /** Remove terminal control sequences while retaining intended line breaks. */
21
+ export declare function sanitizeTerminalText(value: string): string;
@@ -0,0 +1,50 @@
1
+ /** Small, dependency-free terminal styling and display sanitization helpers. */
2
+ const ANSI = {
3
+ accent: "\x1b[36m",
4
+ success: "\x1b[32m",
5
+ warning: "\x1b[33m",
6
+ error: "\x1b[31m",
7
+ info: "\x1b[34m",
8
+ };
9
+ const RESET = "\x1b[39m";
10
+ export function terminalColorEnabled(context = {}) {
11
+ if (context.machineReadable)
12
+ return false;
13
+ const env = context.env ?? process.env;
14
+ const stream = context.stream ?? process.stdout;
15
+ if (Object.hasOwn(env, "NO_COLOR"))
16
+ return false;
17
+ if (env.TERM?.toLowerCase() === "dumb")
18
+ return false;
19
+ if (env.FORCE_COLOR === "0")
20
+ return false;
21
+ if (["1", "2", "3"].includes(env.FORCE_COLOR ?? ""))
22
+ return true;
23
+ if (!stream.isTTY)
24
+ return false;
25
+ // Modern Windows terminals advertise ANSI support. A bare legacy console TTY
26
+ // is not enough evidence, so leave it readable and unstyled.
27
+ if ((context.platform ?? process.platform) === "win32") {
28
+ return Boolean(env.WT_SESSION || env.ANSICON || env.ConEmuANSI === "ON" || env.TERM);
29
+ }
30
+ return true;
31
+ }
32
+ export function styleTerminalText(role, text, context = {}) {
33
+ const safeText = sanitizeTerminalText(text);
34
+ return terminalColorEnabled(context) ? `${ANSI[role]}${safeText}${RESET}` : safeText;
35
+ }
36
+ export function accent(text, context) { return styleTerminalText("accent", text, context); }
37
+ export function success(text, context) { return styleTerminalText("success", text, context); }
38
+ export function warning(text, context) { return styleTerminalText("warning", text, context); }
39
+ export function error(text, context) { return styleTerminalText("error", text, context); }
40
+ export function info(text, context) { return styleTerminalText("info", text, context); }
41
+ /** Remove terminal control sequences while retaining intended line breaks. */
42
+ export function sanitizeTerminalText(value) {
43
+ return value
44
+ // CSI, OSC, DCS/SOS/PM/APC, and two-byte ESC controls.
45
+ .replace(/\x1b\](?:[^\x07\x1b]|\x1b(?!\\))*(?:\x07|\x1b\\)?/gs, "")
46
+ .replace(/\x1b(?:[PX^_](?:.|\n)*?\x1b\\|\[[0-?]*[ -/]*[@-~]|[@-_])/g, "")
47
+ .replace(/\x9d[^\x07]*(?:\x07)?/g, "")
48
+ .replace(/\x9b[0-?]*[ -/]*[@-~]/g, "")
49
+ .replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, "");
50
+ }
@@ -2,6 +2,7 @@ import type { StructuredPlanV1 } from "rafi-spec";
2
2
  import type { TicketDef } from "./tickets/ticketSchema.js";
3
3
  import { type DeliveryConfig } from "./tickets/delivery.js";
4
4
  export interface TicketSliceProposal {
5
+ qa_preparation?: TicketDef["qa_preparation"];
5
6
  slice_ref: string;
6
7
  title: string;
7
8
  area: string;
@@ -1,3 +1,4 @@
1
+ import { canonicalContractJson } from "./qaVerificationContract.js";
1
2
  import { createHash, randomUUID } from "node:crypto";
2
3
  import { closeSync, copyFileSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
4
  import { dirname, join, resolve } from "node:path";
@@ -30,6 +31,9 @@ export function validateTicketPopulationProposal(proposal, plan, existing) {
30
31
  const planned = new Set(plan.slices.map((slice) => slice.slice_ref));
31
32
  const mapped = new Set();
32
33
  for (const ticket of proposal.tickets) {
34
+ const depth = plan.slices.find(slice => slice.slice_ref === ticket.slice_ref)?.qa_preparation;
35
+ if (ticket.qa_preparation && (!depth || canonicalContractJson(ticket.qa_preparation) !== canonicalContractJson(depth)))
36
+ issues.push(`ticket-maker cannot override planner QA depth for ${ticket.slice_ref}`);
33
37
  if (!planned.has(ticket.slice_ref))
34
38
  issues.push(`unknown slice mapping ${ticket.slice_ref}`);
35
39
  if (mapped.has(ticket.slice_ref))
@@ -64,7 +68,7 @@ export function materializeTicketPopulation(proposal, plan, existing) {
64
68
  const materialized = proposal.tickets.map((slice, index) => {
65
69
  const retained = byPlanSlice.get(`${plan.plan_id}:${slice.slice_ref}`);
66
70
  return {
67
- ...(retained ?? {}), id: sliceToTicket.get(slice.slice_ref), order: retained?.order ?? nextOrder(existing, index),
71
+ ...(retained ?? {}), qa_preparation: structuredClone(plan.slices.find(item => item.slice_ref === slice.slice_ref)?.qa_preparation), id: sliceToTicket.get(slice.slice_ref), order: retained?.order ?? nextOrder(existing, index),
68
72
  title: slice.title, area: slice.area, priority: slice.priority, size: slice.size, risk: slice.risk,
69
73
  depends_on: slice.depends_on.map((ref) => sliceToTicket.get(ref)), summary: slice.summary,
70
74
  acceptance: [...slice.acceptance], required_tests: [...slice.required_tests], likely_files: [...slice.likely_files],
@@ -259,8 +259,8 @@ export function cmdBlock(projectDir, ticketId, opts) {
259
259
  ctx.db.upsertState(ticketId, {
260
260
  status: "blocked",
261
261
  blocked_by_json: JSON.stringify(merged),
262
- blocker_type: opts.blockerType ?? "dependency",
263
- blocker_notes: opts.unblockCriteria ?? null,
262
+ blocker_type: opts.blockerType ?? (merged.length ? "dependency" : "external"),
263
+ blocker_notes: opts.unblockCriteria ?? opts.summary ?? "Resolve the external prerequisite before continuing",
264
264
  first_blocked_at: state?.first_blocked_at ?? now,
265
265
  last_checked_at: now,
266
266
  last_worked_at: now,
@@ -31,7 +31,7 @@ export function buildNextQueue(ticketDefs, states, queueLimit) {
31
31
  blockedBy: blockedBy.length ? blockedBy.join(", ") : "None",
32
32
  size: ticket.size,
33
33
  risk: ticket.risk,
34
- nextAction: state?.next_action ?? defaultNextAction(ticket, displayStatus, blockedBy),
34
+ nextAction: state?.next_action ?? (displayStatus === "blocked" && !blockedBy.length ? `Resolve blocker: ${state?.blocker_notes ?? "external prerequisite requires recovery"}` : defaultNextAction(ticket, displayStatus, blockedBy)),
35
35
  requiredTests: ticket.required_tests.join("; "),
36
36
  evidence: state?.evidence ?? "N/A until implemented.",
37
37
  likelyFiles: ticket.likely_files.length ? ticket.likely_files.join(", ") : "unknown",