@zq-silk/yui 0.6.0 → 0.6.2

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 (150) hide show
  1. package/README.md +5 -5
  2. package/dist/agent/managedRuntimeEnvironment.js +2 -1
  3. package/dist/cli/commandCatalog.js +251 -13
  4. package/dist/cli/updateOrchestrator.js +8 -0
  5. package/dist/cli/updatePorts.js +76 -22
  6. package/dist/cli.js +264 -20
  7. package/dist/commands/configCommands.js +83 -9
  8. package/dist/commands/controllerCommands.js +103 -0
  9. package/dist/commands/deliveryGuardPreflight.js +30 -0
  10. package/dist/commands/durableJobCommands.js +231 -0
  11. package/dist/commands/executionAuditCommands.js +193 -0
  12. package/dist/commands/grantCommands.js +374 -0
  13. package/dist/commands/projectCommands.js +119 -81
  14. package/dist/commands/releaseCommands.js +444 -0
  15. package/dist/commands/resourcesCommands.js +274 -0
  16. package/dist/commands/sessionCommands.js +104 -0
  17. package/dist/commands/taskActor.js +117 -0
  18. package/dist/commands/taskChangeSetCommands.js +60 -0
  19. package/dist/commands/taskCommands.js +618 -202
  20. package/dist/commands/taskCompletionGate.js +78 -1
  21. package/dist/commands/taskContextCommand.js +33 -6
  22. package/dist/commands/taskInputCommands.js +1 -1
  23. package/dist/commands/taskIntegrationCommands.js +136 -33
  24. package/dist/commands/taskIntegrationQueueCommands.js +228 -0
  25. package/dist/commands/taskNextActionCommand.js +100 -0
  26. package/dist/commands/taskOverlapCommands.js +120 -0
  27. package/dist/commands/taskOverviewCommand.js +36 -8
  28. package/dist/commands/telemetryCommands.js +330 -0
  29. package/dist/commands/workflowCommands.js +415 -0
  30. package/dist/config/yuiConfig.js +62 -0
  31. package/dist/controller/clientRuntime.js +42 -1
  32. package/dist/controller/controller.js +402 -56
  33. package/dist/controller/controllerMain.js +25 -2
  34. package/dist/controller/domainIdentity.js +16 -8
  35. package/dist/controller/fileSchedulerStoreAdapter.js +423 -31
  36. package/dist/controller/handoverCandidate.js +168 -0
  37. package/dist/controller/jobClient.js +102 -0
  38. package/dist/controller/jobControl.js +613 -0
  39. package/dist/controller/jobSupervisor.js +498 -0
  40. package/dist/controller/providerHookRunFence.js +34 -5
  41. package/dist/controller/resourceCleanupLinux.js +18 -9
  42. package/dist/controller/resourceInventoryLinux.js +90 -39
  43. package/dist/controller/runtime.js +165 -15
  44. package/dist/controller/runtimeEventInbox.js +234 -57
  45. package/dist/controller/runtimeEventProcessor.js +297 -58
  46. package/dist/controller/sessionOwnerReconciliation.js +321 -0
  47. package/dist/core/controllerServer.js +416 -27
  48. package/dist/core/controllerTelemetry.js +167 -0
  49. package/dist/doctor/doctor.js +113 -16
  50. package/dist/domain/validation.js +9 -0
  51. package/dist/execution/executionGroup.js +40 -3
  52. package/dist/executor/agentExecutor.js +6 -3
  53. package/dist/executor/effectiveLaunch.js +52 -0
  54. package/dist/executor/executorRegistry.js +50 -0
  55. package/dist/executor/fileRoleLaunchPlanner.js +61 -6
  56. package/dist/grant/capabilityGrant.js +282 -0
  57. package/dist/integration/changeSet.js +16 -3
  58. package/dist/integration/changeSetManifest.js +46 -0
  59. package/dist/integration/gitIntegrationService.js +528 -147
  60. package/dist/integration/integrationAttempt.js +54 -5
  61. package/dist/integration/integrationQueueEntry.js +221 -0
  62. package/dist/integration/integrationQueueService.js +955 -0
  63. package/dist/integration/manifestTags.js +99 -0
  64. package/dist/integration/overlapDiagnostics.js +211 -0
  65. package/dist/job/durableJob.js +449 -0
  66. package/dist/job/jobRunner.js +350 -0
  67. package/dist/lifecycle/exactRunTerminalization.js +24 -2
  68. package/dist/lifecycle/providerErrorClass.js +126 -0
  69. package/dist/message/message.js +16 -3
  70. package/dist/observability/executionAudit.js +545 -0
  71. package/dist/observability/faultClassification.js +160 -0
  72. package/dist/observability/runtimeIdentity.js +367 -0
  73. package/dist/release/fakeReleasePorts.js +55 -0
  74. package/dist/release/releaseHandover.js +475 -0
  75. package/dist/release/releaseIdempotencyStore.js +165 -0
  76. package/dist/release/releaseWorkflow.js +459 -0
  77. package/dist/release/releaseWorkflowEngine.js +688 -0
  78. package/dist/release/releaseWorkflowPorts.js +1720 -0
  79. package/dist/release/runtimeRelease.js +495 -0
  80. package/dist/release/workflowFileLock.js +218 -0
  81. package/dist/repository/gitWorkspace.js +177 -1
  82. package/dist/repository/projectMaintenanceLock.js +315 -0
  83. package/dist/repository/taskWorkspaceCoordinator.js +87 -17
  84. package/dist/repository/taskWorkspacePreparer.js +1091 -517
  85. package/dist/resources/autoResourceGc.js +116 -0
  86. package/dist/resources/liveReferences.js +574 -0
  87. package/dist/resources/resourceDiscovery.js +477 -0
  88. package/dist/resources/resourceGc.js +645 -0
  89. package/dist/resources/resourceRegistrar.js +256 -0
  90. package/dist/resources/resourceRegistry.js +150 -0
  91. package/dist/resources/resourceRegistryStore.js +41 -0
  92. package/dist/resources/resourceTypes.js +42 -0
  93. package/dist/resources/sqliteResourceRegistry.js +111 -0
  94. package/dist/review/reviewConfig.js +10 -0
  95. package/dist/review/reviewFinding.js +240 -0
  96. package/dist/review/reviewFindingLedger.js +545 -0
  97. package/dist/review/reviewOutcomeClassifier.js +61 -0
  98. package/dist/review/reviewRound.js +56 -4
  99. package/dist/run/agentRun.js +80 -4
  100. package/dist/run/providerRetry.js +84 -0
  101. package/dist/run/providerRetryConfig.js +63 -0
  102. package/dist/run/yieldReceipt.js +65 -0
  103. package/dist/runtime/exactControlPlane.js +79 -2
  104. package/dist/runtime/index.js +4 -0
  105. package/dist/runtime/sessionOwnerIdentity.js +269 -0
  106. package/dist/runtime/sessionOwnerRegistry.js +132 -0
  107. package/dist/runtime/sessionReconciliation.js +93 -0
  108. package/dist/runtime/sessionTerminationGuard.js +211 -0
  109. package/dist/runtime/taskRuntimeIsolation.js +13 -0
  110. package/dist/runtime/tmuxAdapters.js +34 -1
  111. package/dist/scheduler/actionability.js +155 -0
  112. package/dist/scheduler/activeRoleRunDelivery.js +14 -5
  113. package/dist/scheduler/activeTaskProgress.js +60 -0
  114. package/dist/scheduler/leaderWakeupProcessor.js +22 -11
  115. package/dist/scheduler/roleRunStall.js +135 -29
  116. package/dist/scheduler/taskExecutionProjection.js +11 -0
  117. package/dist/storage/compatibleTaskStore.js +112 -5
  118. package/dist/storage/migration/productionRegistry.js +736 -1
  119. package/dist/storage/sqliteSchema.js +264 -3
  120. package/dist/storage/sqliteStore.js +487 -13
  121. package/dist/storage/storeRpc.js +21 -0
  122. package/dist/storage/taskStore.js +974 -21
  123. package/dist/storage/upgrade/homeClassification.js +120 -2
  124. package/dist/storage/upgrade/migrationReceipt.js +67 -0
  125. package/dist/storage/upgrade/pseudoLayoutRepair.js +241 -0
  126. package/dist/storage/upgrade/recordVersions.js +10 -1
  127. package/dist/storage/upgrade/sqliteMigrationTarget.js +58 -6
  128. package/dist/storage/upgrade/sqliteRecordMigrationTarget.js +290 -0
  129. package/dist/storage/upgrade/sqliteStateMigration.js +258 -2
  130. package/dist/storage/upgrade/upgradeOrchestrator.js +482 -16
  131. package/dist/task/deliveryGuard.js +226 -0
  132. package/dist/task/nextAction.js +738 -0
  133. package/dist/task/repairWave.js +137 -0
  134. package/dist/task/taskRecordReference.js +6 -1
  135. package/dist/telemetry/sqliteTelemetryStore.js +387 -0
  136. package/dist/telemetry/telemetryCompaction.js +251 -0
  137. package/dist/telemetry/telemetryConfig.js +64 -0
  138. package/dist/telemetry/telemetryRouter.js +32 -0
  139. package/dist/telemetry/telemetryStore.js +19 -0
  140. package/dist/telemetry/telemetryWiring.js +33 -0
  141. package/dist/tmux/tmuxManager.js +20 -1
  142. package/dist/tmux/tmuxSocketEndpoint.js +20 -0
  143. package/dist/verification/gateArtifact.js +216 -0
  144. package/dist/verification/gateArtifactStore.js +87 -0
  145. package/dist/verification/verificationGateService.js +414 -0
  146. package/dist/verification/verificationPlan.js +308 -0
  147. package/dist/workspace/gitChangeSetCapture.js +12 -2
  148. package/dist/workspace/workItemChangeSetManager.js +60 -3
  149. package/package.json +1 -1
  150. package/skills/yui-leader/SKILL.md +8 -0
@@ -0,0 +1,350 @@
1
+ /**
2
+ * Standalone durable-job runner.
3
+ *
4
+ * Receives exactly one argument: the absolute path to a `spec.json` written
5
+ * atomically by the Controller. The runner MUST NOT open state.json or any
6
+ * other control-plane file; its entire contract is the spec + the artifact
7
+ * directory. The Controller supervises the runner through the artifact files
8
+ * (heartbeat, checkpoint.json, exit.json) and the process identity.
9
+ */
10
+ import { spawn } from "node:child_process";
11
+ import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, utimesSync, writeFileSync } from "node:fs";
12
+ import { join, resolve } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ import { writeTextFileAtomically } from "../storage/durableFile.js";
15
+ import { readLinuxProcessStartIdentity } from "../controller/domainIdentity.js";
16
+ const DEFAULT_STEP_TIMEOUT_MS = 30 * 60_000;
17
+ const HEARTBEAT_INTERVAL_MS = 5_000;
18
+ const SIGKILL_GRACE_MS = 2_000;
19
+ const CANCEL_FENCE = "cancel";
20
+ /**
21
+ * The bounded grace between SIGTERM and SIGKILL for a step that ignores the
22
+ * termination request. Scales with the step timeout (capped) so even the
23
+ * shortest timeout converges in tests while real checks get the full grace.
24
+ */
25
+ function sigkillGraceMs(stepTimeoutMs) {
26
+ return Math.min(SIGKILL_GRACE_MS, Math.max(100, Math.floor(stepTimeoutMs / 2)));
27
+ }
28
+ export async function runDurableJobRunner(specPath) {
29
+ const spec = readSpec(specPath);
30
+ const artifactDir = resolve(spec.artifactDir);
31
+ const logsDir = join(artifactDir, "logs");
32
+ mkdirSync(logsDir, { recursive: true, mode: 0o700 });
33
+ chmodSync(artifactDir, 0o700);
34
+ const heartbeatPath = join(artifactDir, "heartbeat");
35
+ const checkpointPath = join(artifactDir, "checkpoint.json");
36
+ const exitPath = join(artifactDir, "exit.json");
37
+ const cancelPath = join(artifactDir, CANCEL_FENCE);
38
+ touchHeartbeat(heartbeatPath);
39
+ const heartbeatTimer = setInterval(() => touchHeartbeat(heartbeatPath), HEARTBEAT_INTERVAL_MS);
40
+ heartbeatTimer.unref();
41
+ // f3: Durable startup handshake. Write ready.json with O_EXCL before any
42
+ // step side effect. If the file already exists, another runner instance
43
+ // already started — exit to prevent duplicate execution. The supervisor
44
+ // reads this file to adopt a queued job after a Controller crash.
45
+ //
46
+ // rr4/finding-4: The runner writes its own OS startIdentity so the
47
+ // supervisor can adopt without re-reading /proc (which could return a
48
+ // reused PID's identity after the original runner exited).
49
+ const readyPath = join(artifactDir, "ready.json");
50
+ try {
51
+ const readyFd = openSync(readyPath, "wx", 0o600);
52
+ writeFileSync(readyFd, JSON.stringify({
53
+ pid: process.pid,
54
+ startIdentity: readLinuxProcessStartIdentity(process.pid) ?? String(process.pid),
55
+ startedAt: new Date().toISOString()
56
+ }, null, 2) + "\n");
57
+ closeSync(readyFd);
58
+ }
59
+ catch (error) {
60
+ if (error.code === "EEXIST") {
61
+ // Another runner already started for this job. Exit cleanly.
62
+ return;
63
+ }
64
+ throw error;
65
+ }
66
+ let cancelled = false;
67
+ let currentChild = null;
68
+ // f4: Kill the entire process group so background descendants cannot
69
+ // survive the runner's terminal exit. Each step runs in its own group
70
+ // (detached: true), and negative-pid kill targets the whole group.
71
+ const killProcessGroup = (child, signal) => {
72
+ if (child.pid === undefined)
73
+ return;
74
+ try {
75
+ process.kill(-child.pid, signal);
76
+ }
77
+ catch (error) {
78
+ if (error.code !== "ESRCH")
79
+ throw error;
80
+ }
81
+ };
82
+ // f2/rr5: The SIGTERM handler must stay installed and idempotent for the
83
+ // runner's whole lifetime. A repeated SIGTERM (supervisor re-send, stale-
84
+ // heartbeat escalation, or a second operator cancel) must NOT bypass the
85
+ // graceful exit.json write: the first signal sets `cancelled` and kills the
86
+ // step; subsequent signals during the drain are ignored so the runner can
87
+ // finish writing exit.json before exiting. Using `process.on` (not `once`)
88
+ // keeps the handler installed; the early return makes re-entry a no-op.
89
+ const onSignal = () => {
90
+ if (cancelled)
91
+ return;
92
+ cancelled = true;
93
+ if (currentChild !== null) {
94
+ killProcessGroup(currentChild, "SIGTERM");
95
+ // A child that ignores SIGTERM must not pin the runner: escalate so the
96
+ // job still reaches its bounded terminal result.
97
+ setTimeout(() => {
98
+ if (currentChild !== null)
99
+ killProcessGroup(currentChild, "SIGKILL");
100
+ }, SIGKILL_GRACE_MS).unref();
101
+ }
102
+ };
103
+ process.on("SIGTERM", onSignal);
104
+ process.on("SIGINT", onSignal);
105
+ const completedSteps = [];
106
+ let outcome = "succeeded";
107
+ let exitCode = 0;
108
+ let signal = null;
109
+ let failedStep;
110
+ try {
111
+ for (let index = 0; index < spec.steps.length; index += 1) {
112
+ if (cancelled || existsSync(cancelPath)) {
113
+ cancelled = true;
114
+ outcome = "cancelled";
115
+ exitCode = null;
116
+ break;
117
+ }
118
+ touchHeartbeat(heartbeatPath);
119
+ const step = spec.steps[index];
120
+ const stepNumber = String(index + 1).padStart(3, "0");
121
+ const logName = `${stepNumber}-${sanitizeLogName(step.name)}.log`;
122
+ const logPath = join(logsDir, logName);
123
+ const logFd = openSync(logPath, "w", 0o600);
124
+ const stepTimeoutMs = step.timeoutMs ?? spec.defaultStepTimeoutMs ?? DEFAULT_STEP_TIMEOUT_MS;
125
+ const startedAt = Date.now();
126
+ let timedOut = false;
127
+ let child;
128
+ try {
129
+ // Issue 08: a structured argv step executes without a shell so its
130
+ // tokens can never be reinterpreted; a legacy command step keeps the
131
+ // shell form. A per-step cwd/env override applies to either form.
132
+ const stepCwd = step.cwd ?? spec.workspace;
133
+ const stepEnv = step.env === undefined
134
+ ? spec.env
135
+ : { ...spec.env, ...step.env };
136
+ // f4: a new process group (detached) lets the runner kill the whole
137
+ // step tree on timeout or cancel, for both argv and shell forms.
138
+ if (step.argv !== undefined) {
139
+ const [file, ...args] = step.argv;
140
+ child = spawn(file, args, {
141
+ cwd: stepCwd,
142
+ env: stepEnv,
143
+ stdio: ["ignore", logFd, logFd],
144
+ detached: true
145
+ });
146
+ }
147
+ else {
148
+ child = spawn("/bin/sh", ["-lc", step.command], {
149
+ cwd: stepCwd,
150
+ env: stepEnv,
151
+ stdio: ["ignore", logFd, logFd],
152
+ detached: true
153
+ });
154
+ }
155
+ }
156
+ catch (error) {
157
+ closeSync(logFd);
158
+ completedSteps.push({
159
+ name: step.name,
160
+ exitCode: null,
161
+ signal: null,
162
+ timedOut: false,
163
+ durationMs: Date.now() - startedAt,
164
+ logPath: logName,
165
+ head: spec.head
166
+ });
167
+ outcome = "failed";
168
+ exitCode = null;
169
+ failedStep = step.name;
170
+ writeCheckpoint(checkpointPath, completedSteps);
171
+ break;
172
+ }
173
+ currentChild = child;
174
+ let sigkillTimer;
175
+ const timeout = setTimeout(() => {
176
+ timedOut = true;
177
+ killProcessGroup(child, "SIGTERM");
178
+ // A step that ignores SIGTERM must still converge: escalate to
179
+ // SIGKILL after the bounded grace so exit.json is always written.
180
+ sigkillTimer = setTimeout(() => {
181
+ killProcessGroup(child, "SIGKILL");
182
+ }, sigkillGraceMs(stepTimeoutMs));
183
+ sigkillTimer.unref();
184
+ }, stepTimeoutMs);
185
+ timeout.unref();
186
+ const result = await new Promise((resolvePromise) => {
187
+ child.once("error", () => {
188
+ resolvePromise({ code: null, signal: null });
189
+ });
190
+ child.once("close", (code, sig) => {
191
+ resolvePromise({ code, signal: sig ?? null });
192
+ });
193
+ });
194
+ clearTimeout(timeout);
195
+ if (sigkillTimer !== undefined)
196
+ clearTimeout(sigkillTimer);
197
+ closeSync(logFd);
198
+ currentChild = null;
199
+ const stepResult = {
200
+ name: step.name,
201
+ exitCode: result.code,
202
+ signal: result.signal,
203
+ timedOut,
204
+ durationMs: Date.now() - startedAt,
205
+ logPath: logName,
206
+ head: spec.head
207
+ };
208
+ completedSteps.push(stepResult);
209
+ writeCheckpoint(checkpointPath, completedSteps);
210
+ if (cancelled) {
211
+ outcome = "cancelled";
212
+ exitCode = null;
213
+ break;
214
+ }
215
+ if (timedOut) {
216
+ outcome = "timed-out";
217
+ exitCode = result.code;
218
+ signal = result.signal;
219
+ failedStep = step.name;
220
+ break;
221
+ }
222
+ if (result.code !== 0) {
223
+ outcome = "failed";
224
+ exitCode = result.code;
225
+ signal = result.signal;
226
+ failedStep = step.name;
227
+ break;
228
+ }
229
+ }
230
+ }
231
+ finally {
232
+ clearInterval(heartbeatTimer);
233
+ // f2/rr5: Do NOT remove the signal handlers here. A repeated SIGTERM
234
+ // between the step loop and the exit.json write must still hit the
235
+ // idempotent handler (which ignores it) rather than the default handler
236
+ // (which would kill the process before exit.json is written). The
237
+ // handlers are removed after the exit.json write below.
238
+ }
239
+ const exit = {
240
+ outcome,
241
+ exitCode,
242
+ signal,
243
+ ...(failedStep === undefined ? {} : { failedStep }),
244
+ steps: completedSteps,
245
+ finishedAt: new Date().toISOString()
246
+ };
247
+ writeTextFileAtomically(exitPath, `${JSON.stringify(exit, null, 2)}\n`);
248
+ // f2/rr5: exit.json is durable — the signal handlers are no longer needed.
249
+ process.removeListener("SIGTERM", onSignal);
250
+ process.removeListener("SIGINT", onSignal);
251
+ }
252
+ function readSpec(specPath) {
253
+ const resolved = resolve(specPath);
254
+ const raw = readJsonFile(resolved);
255
+ const spec = raw;
256
+ if (typeof spec.jobId !== "string" || spec.jobId.length === 0) {
257
+ throw new Error("DurableJob spec is missing jobId.");
258
+ }
259
+ if (typeof spec.taskId !== "string" || spec.taskId.length === 0) {
260
+ throw new Error("DurableJob spec is missing taskId.");
261
+ }
262
+ if (typeof spec.workspace !== "string" || !spec.workspace.startsWith("/")) {
263
+ throw new Error("DurableJob spec workspace must be an absolute path.");
264
+ }
265
+ if (typeof spec.artifactDir !== "string" || !spec.artifactDir.startsWith("/")) {
266
+ throw new Error("DurableJob spec artifactDir must be an absolute path.");
267
+ }
268
+ if (typeof spec.head !== "string" || spec.head.length === 0) {
269
+ throw new Error("DurableJob spec is missing head.");
270
+ }
271
+ if (typeof spec.env !== "object" || spec.env === null || Array.isArray(spec.env)) {
272
+ throw new Error("DurableJob spec env must be a map.");
273
+ }
274
+ if (!Array.isArray(spec.steps) || spec.steps.length === 0) {
275
+ throw new Error("DurableJob spec must have at least one step.");
276
+ }
277
+ for (const step of spec.steps) {
278
+ if (typeof step.name !== "string" || step.name.length === 0) {
279
+ throw new Error("DurableJob spec step is missing name.");
280
+ }
281
+ if (typeof step.command !== "string" || step.command.length === 0) {
282
+ throw new Error(`DurableJob spec step ${step.name} is missing command.`);
283
+ }
284
+ if (step.argv !== undefined) {
285
+ if (!Array.isArray(step.argv) || step.argv.length === 0
286
+ || step.argv.some((value) => typeof value !== "string" || value.length === 0)) {
287
+ throw new Error(`DurableJob spec step ${step.name} argv is invalid.`);
288
+ }
289
+ }
290
+ if (step.cwd !== undefined
291
+ && (typeof step.cwd !== "string" || !step.cwd.startsWith("/"))) {
292
+ throw new Error(`DurableJob spec step ${step.name} cwd must be absolute.`);
293
+ }
294
+ if (step.env !== undefined
295
+ && (typeof step.env !== "object" || step.env === null || Array.isArray(step.env))) {
296
+ throw new Error(`DurableJob spec step ${step.name} env must be a map.`);
297
+ }
298
+ if (step.timeoutMs !== undefined
299
+ && (!Number.isSafeInteger(step.timeoutMs) || step.timeoutMs < 1)) {
300
+ throw new Error(`DurableJob spec step ${step.name} timeoutMs is invalid.`);
301
+ }
302
+ }
303
+ return spec;
304
+ }
305
+ function readJsonFile(path) {
306
+ const raw = readFileSync(path, "utf8");
307
+ return JSON.parse(raw);
308
+ }
309
+ function touchHeartbeat(path) {
310
+ try {
311
+ utimesSync(path, new Date(), new Date());
312
+ }
313
+ catch {
314
+ try {
315
+ const fd = openSync(path, "w", 0o600);
316
+ closeSync(fd);
317
+ }
318
+ catch {
319
+ // Best-effort liveness proof; the supervisor detects staleness.
320
+ }
321
+ }
322
+ }
323
+ function writeCheckpoint(path, completedSteps) {
324
+ const checkpoint = {
325
+ completedSteps,
326
+ updatedAt: new Date().toISOString()
327
+ };
328
+ writeTextFileAtomically(path, `${JSON.stringify(checkpoint, null, 2)}\n`);
329
+ }
330
+ function sanitizeLogName(name) {
331
+ return name.replace(/[^a-zA-Z0-9._-]/gu, "_").slice(0, 80) || "step";
332
+ }
333
+ function isEntrypoint() {
334
+ return process.argv[1] !== undefined
335
+ && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
336
+ }
337
+ if (isEntrypoint()) {
338
+ const specPath = process.argv[2];
339
+ if (typeof specPath !== "string" || specPath.length === 0) {
340
+ process.stderr.write("Usage: jobRunner.js <spec.json>\n");
341
+ process.exitCode = 1;
342
+ }
343
+ else {
344
+ void runDurableJobRunner(specPath).catch((error) => {
345
+ const message = error instanceof Error ? error.message : String(error);
346
+ process.stderr.write(`DurableJob runner failed: ${message}\n`);
347
+ process.exitCode = 1;
348
+ });
349
+ }
350
+ }
@@ -6,7 +6,9 @@ import { terminalizeTaskRoleRunSession } from "../executor/agentExecutor.js";
6
6
  import { updateRoleStatus } from "../role/role.js";
7
7
  import { createTaskEvent } from "../event/taskEvent.js";
8
8
  import { finishReviewRound, updateReviewExecutionGroup } from "../review/reviewRound.js";
9
- import { failAgentRun, yieldAgentRun } from "../run/agentRun.js";
9
+ import { reconcileReviewFindingsAfterReview } from "../review/reviewFindingLedger.js";
10
+ import { failAgentRun, withYieldReceipt, yieldAgentRun } from "../run/agentRun.js";
11
+ import { createYieldReceipt } from "../run/yieldReceipt.js";
10
12
  import { recordExecutionLaneResult } from "../execution/executionGroup.js";
11
13
  import { formatAgentRunReceiptId } from "../task/taskRecordReference.js";
12
14
  import { isRuntimeLaunchReservation, runtimeLifecycleTarget } from "../runtime/lifecycleReservation.js";
@@ -178,6 +180,11 @@ export function terminalizeExactRunReviewRound(store, input, now) {
178
180
  }
179
181
  const terminal = finishReviewRound(groupedRound, input.outcome.status === "yielded" ? "completed" : "failed", input.outcome.summary, now, input.reviewResult);
180
182
  store.saveReviewRound(input.taskId, terminal);
183
+ // Issue 06: a completed (semantic) Round feeds the finding ledger; a failed
184
+ // execution attempt is skipped by the classifier and never creates findings.
185
+ if (terminal.status === "completed") {
186
+ reconcileReviewFindingsAfterReview(store, input.taskId, terminal.id, now);
187
+ }
181
188
  return { disposition: "applied", round: terminal };
182
189
  }
183
190
  /**
@@ -264,7 +271,22 @@ export function terminalizeExactTaskRun(store, input, now) {
264
271
  return obsolete(run, reviewRoundTerminalization.reason ?? "review-round-mismatch");
265
272
  }
266
273
  const terminal = input.outcome.status === "yielded"
267
- ? yieldAgentRun(run, input.outcome.summary, now)
274
+ ? withYieldReceipt(yieldAgentRun(run, input.outcome.summary, now), createYieldReceipt(input.taskId, input.runId, {
275
+ status: "yielded",
276
+ summary: input.outcome.summary,
277
+ ...(input.reviewResult === undefined
278
+ ? {}
279
+ : {
280
+ reviewResult: {
281
+ ...(input.reviewResult.report === undefined ? {} : { report: input.reviewResult.report }),
282
+ ...(input.reviewResult.checks === undefined ? {} : { checks: input.reviewResult.checks }),
283
+ ...(input.reviewResult.findings === undefined ? {} : { findings: input.reviewResult.findings }),
284
+ ...(input.reviewResult.evidence === undefined ? {} : { evidence: input.reviewResult.evidence }),
285
+ ...(input.reviewResult.evidenceCommit === undefined ? {} : { evidenceCommit: input.reviewResult.evidenceCommit }),
286
+ ...(input.reviewResult.gitSnapshot === undefined ? {} : { gitSnapshot: input.reviewResult.gitSnapshot })
287
+ }
288
+ })
289
+ }, now))
268
290
  : failAgentRun(run, input.outcome.summary, now);
269
291
  if (run.executionGroupId !== undefined
270
292
  && run.executionLaneId !== undefined
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Issue 04 — Provider error classification.
3
+ *
4
+ * Provider failures arrive as opaque free text (Claude StopFailure
5
+ * `error`/`errorDetails`, Codex turn-completion summaries). The retry-in-place
6
+ * coordinator needs a stable, provider-neutral error class before it can decide
7
+ * whether the original Session may be retried. This module is the single
8
+ * classifier: it is pure, table-driven, and deliberately conservative.
9
+ *
10
+ * Classes (Issue 04 §2):
11
+ * - `transient-provider` — 500/502/504, connection reset, backend capacity;
12
+ * the original Session is retried in place.
13
+ * - `transport-uncertain` — the request may have been accepted but the
14
+ * response was lost; native facts are consulted
15
+ * before any resend.
16
+ * - `policy-denied` — cyber_policy / permission boundary; never retried
17
+ * automatically, never worked around by switching
18
+ * Session or widening permission.
19
+ * - `session-dead` — the process/tmux/native identity is gone; in-place
20
+ * retry stops and a replacement blocker is raised.
21
+ * - `invalid-request` — deterministic parameter/protocol error; fail fast,
22
+ * never call the Provider again.
23
+ * - `unclassified` — no conservative match; behaves like
24
+ * `invalid-request` for retry purposes (old
25
+ * terminalize-immediately behavior) while remaining
26
+ * observable in shadow metrics.
27
+ */
28
+ /** Classes for which the original Session may be retried in place. */
29
+ export const RETRYABLE_PROVIDER_ERROR_CLASSES = [
30
+ "transient-provider",
31
+ "transport-uncertain"
32
+ ];
33
+ export function isRetryableProviderErrorClass(errorClass) {
34
+ return RETRYABLE_PROVIDER_ERROR_CLASSES.includes(errorClass);
35
+ }
36
+ /**
37
+ * Ordered pattern tables. The first class whose pattern matches wins, so the
38
+ * table order is the precedence order. Patterns are matched case-insensitively
39
+ * against the concatenation of every available text field.
40
+ */
41
+ const SESSION_DEAD_PATTERNS = [
42
+ { pattern: /session not found/iu, label: "session-not-found" },
43
+ { pattern: /no such (session|thread)/iu, label: "no-such-session" },
44
+ { pattern: /thread not found/iu, label: "thread-not-found" },
45
+ { pattern: /session (has )?expired/iu, label: "session-expired" },
46
+ { pattern: /session (has )?ended/iu, label: "session-ended" },
47
+ { pattern: /session (is )?dead/iu, label: "session-dead" },
48
+ { pattern: /session terminated/iu, label: "session-terminated" },
49
+ { pattern: /process exited/iu, label: "process-exited" },
50
+ { pattern: /pane (is )?dead/iu, label: "pane-dead" },
51
+ { pattern: /target session/iu, label: "target-session" }
52
+ ];
53
+ const POLICY_DENIED_PATTERNS = [
54
+ { pattern: /cyber[_-]?policy/iu, label: "cyber-policy" },
55
+ { pattern: /policy[_-]?violation/iu, label: "policy-violation" },
56
+ { pattern: /usage[_-]?policy/iu, label: "usage-policy" },
57
+ { pattern: /content[_-]?policy/iu, label: "content-policy" },
58
+ { pattern: /safety[_-]?policy/iu, label: "safety-policy" },
59
+ { pattern: /policy denial/iu, label: "policy-denial" }
60
+ ];
61
+ const INVALID_REQUEST_PATTERNS = [
62
+ { pattern: /invalid[_-]?request/iu, label: "invalid-request" },
63
+ { pattern: /validation error/iu, label: "validation-error" },
64
+ { pattern: /bad request/iu, label: "bad-request" },
65
+ { pattern: /\b400\b/u, label: "http-400" },
66
+ { pattern: /unknown (flag|tool|argument)/iu, label: "unknown-argument" },
67
+ { pattern: /unexpected argument/iu, label: "unexpected-argument" },
68
+ { pattern: /invalid schema/iu, label: "invalid-schema" }
69
+ ];
70
+ const TRANSIENT_PROVIDER_PATTERNS = [
71
+ { pattern: /\b50[024]\b/u, label: "http-5xx" },
72
+ { pattern: /server[_-]?error/iu, label: "server-error" },
73
+ { pattern: /internal server error/iu, label: "internal-server-error" },
74
+ { pattern: /connection lost/iu, label: "connection-lost" },
75
+ { pattern: /connection reset/iu, label: "connection-reset" },
76
+ { pattern: /econnreset/iu, label: "econnreset" },
77
+ { pattern: /socket hang up/iu, label: "socket-hang-up" },
78
+ { pattern: /kv[_-]?cache[_-]?allocate[_-]?failed/iu, label: "kv-cache-allocate-failed" },
79
+ { pattern: /overloaded/iu, label: "overloaded" },
80
+ { pattern: /\b429\b/u, label: "http-429" },
81
+ { pattern: /rate[_-]?limit/iu, label: "rate-limit" },
82
+ { pattern: /upstream/iu, label: "upstream" },
83
+ { pattern: /bad gateway/iu, label: "bad-gateway" },
84
+ { pattern: /gateway timeout/iu, label: "gateway-timeout" },
85
+ { pattern: /service unavailable/iu, label: "service-unavailable" },
86
+ { pattern: /temporarily unavailable/iu, label: "temporarily-unavailable" },
87
+ { pattern: /try again/iu, label: "try-again" }
88
+ ];
89
+ const TRANSPORT_UNCERTAIN_PATTERNS = [
90
+ { pattern: /timed?[ -]?out/iu, label: "timeout" },
91
+ { pattern: /etimedout/iu, label: "etimedout" },
92
+ { pattern: /response lost/iu, label: "response-lost" },
93
+ { pattern: /lost response/iu, label: "lost-response" },
94
+ { pattern: /stream interrupted/iu, label: "stream-interrupted" },
95
+ { pattern: /interrupted function/iu, label: "interrupted-function" },
96
+ { pattern: /controller timeout/iu, label: "controller-timeout" },
97
+ { pattern: /delivery (unconfirmed|uncertain|not confirmed)/iu, label: "delivery-unconfirmed" },
98
+ { pattern: /unconfirmed delivery/iu, label: "unconfirmed-delivery" },
99
+ { pattern: /no response/iu, label: "no-response" }
100
+ ];
101
+ const CLASS_TABLE = [
102
+ { errorClass: "session-dead", patterns: SESSION_DEAD_PATTERNS },
103
+ { errorClass: "policy-denied", patterns: POLICY_DENIED_PATTERNS },
104
+ { errorClass: "invalid-request", patterns: INVALID_REQUEST_PATTERNS },
105
+ { errorClass: "transient-provider", patterns: TRANSIENT_PROVIDER_PATTERNS },
106
+ { errorClass: "transport-uncertain", patterns: TRANSPORT_UNCERTAIN_PATTERNS }
107
+ ];
108
+ /**
109
+ * Classifies one provider failure. Every available text field is concatenated
110
+ * so a class can be recognized regardless of which field carried it. Unknown
111
+ * text is `unclassified`, which keeps the old fail-immediately behavior.
112
+ */
113
+ export function classifyProviderError(input) {
114
+ const text = [input.error, input.errorDetails, input.summary]
115
+ .filter((value) => typeof value === "string" && value.length > 0)
116
+ .join("\n");
117
+ if (text.length === 0)
118
+ return { errorClass: "unclassified", matched: "none" };
119
+ for (const { errorClass, patterns } of CLASS_TABLE) {
120
+ for (const { pattern, label } of patterns) {
121
+ if (pattern.test(text))
122
+ return { errorClass, matched: label };
123
+ }
124
+ }
125
+ return { errorClass: "unclassified", matched: "none" };
126
+ }
@@ -3,12 +3,15 @@ export const TASK_MESSAGE_KINDS = ["user", "operator", "role-result", "system"];
3
3
  export function createTaskMessage(id, taskId, body, kind, author, now, context = {}) {
4
4
  validateKindAndAuthor(kind, author);
5
5
  const message = {
6
- schemaVersion: 2,
6
+ schemaVersion: 3,
7
7
  id: requireSafeIdentity(id, "Message id"),
8
8
  taskId: requireSafeIdentity(taskId, "Message Task id"),
9
9
  kind,
10
10
  author: normalizeAuthor(author),
11
11
  body: requireBody(body),
12
+ ...(context.wakePolicy === undefined
13
+ ? {}
14
+ : { wakePolicy: context.wakePolicy }),
12
15
  ...(context.runId === undefined
13
16
  ? {}
14
17
  : { runId: requireSafeIdentity(context.runId, "Message Run id") }),
@@ -24,12 +27,22 @@ export function taskMessageAuthorLabel(author) {
24
27
  return author.type === "role" ? author.roleName : author.type;
25
28
  }
26
29
  export function validateTaskMessage(message) {
27
- if (message.schemaVersion !== 2)
28
- throw new Error("Task Message must use schemaVersion 2.");
30
+ if (message.schemaVersion !== 3)
31
+ throw new Error("Task Message must use schemaVersion 3.");
29
32
  validateTaskRecordReference({ taskId: message.taskId, localId: message.id }, "message");
30
33
  requireText(message.body, "Message body");
31
34
  validateKindAndAuthor(message.kind, message.author);
32
35
  normalizeAuthor(message.author);
36
+ if (message.wakePolicy !== undefined
37
+ && message.wakePolicy !== "leader"
38
+ && message.wakePolicy !== "none") {
39
+ throw new Error(`Message wakePolicy is invalid: ${String(message.wakePolicy)}.`);
40
+ }
41
+ if (message.wakePolicy !== undefined
42
+ && message.kind !== "user"
43
+ && message.kind !== "operator") {
44
+ throw new Error("Message wakePolicy is only valid for user/operator messages.");
45
+ }
33
46
  if (message.runId !== undefined)
34
47
  requireSafeIdentity(message.runId, "Message Run id");
35
48
  if (message.workItemId !== undefined) {