@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.
- package/README.md +5 -5
- package/dist/agent/managedRuntimeEnvironment.js +2 -1
- package/dist/cli/commandCatalog.js +251 -13
- package/dist/cli/updateOrchestrator.js +8 -0
- package/dist/cli/updatePorts.js +76 -22
- package/dist/cli.js +264 -20
- package/dist/commands/configCommands.js +83 -9
- package/dist/commands/controllerCommands.js +103 -0
- package/dist/commands/deliveryGuardPreflight.js +30 -0
- package/dist/commands/durableJobCommands.js +231 -0
- package/dist/commands/executionAuditCommands.js +193 -0
- package/dist/commands/grantCommands.js +374 -0
- package/dist/commands/projectCommands.js +119 -81
- package/dist/commands/releaseCommands.js +444 -0
- package/dist/commands/resourcesCommands.js +274 -0
- package/dist/commands/sessionCommands.js +104 -0
- package/dist/commands/taskActor.js +117 -0
- package/dist/commands/taskChangeSetCommands.js +60 -0
- package/dist/commands/taskCommands.js +618 -202
- package/dist/commands/taskCompletionGate.js +78 -1
- package/dist/commands/taskContextCommand.js +33 -6
- package/dist/commands/taskInputCommands.js +1 -1
- package/dist/commands/taskIntegrationCommands.js +136 -33
- package/dist/commands/taskIntegrationQueueCommands.js +228 -0
- package/dist/commands/taskNextActionCommand.js +100 -0
- package/dist/commands/taskOverlapCommands.js +120 -0
- package/dist/commands/taskOverviewCommand.js +36 -8
- package/dist/commands/telemetryCommands.js +330 -0
- package/dist/commands/workflowCommands.js +415 -0
- package/dist/config/yuiConfig.js +62 -0
- package/dist/controller/clientRuntime.js +42 -1
- package/dist/controller/controller.js +402 -56
- package/dist/controller/controllerMain.js +25 -2
- package/dist/controller/domainIdentity.js +16 -8
- package/dist/controller/fileSchedulerStoreAdapter.js +423 -31
- package/dist/controller/handoverCandidate.js +168 -0
- package/dist/controller/jobClient.js +102 -0
- package/dist/controller/jobControl.js +613 -0
- package/dist/controller/jobSupervisor.js +498 -0
- package/dist/controller/providerHookRunFence.js +34 -5
- package/dist/controller/resourceCleanupLinux.js +18 -9
- package/dist/controller/resourceInventoryLinux.js +90 -39
- package/dist/controller/runtime.js +165 -15
- package/dist/controller/runtimeEventInbox.js +234 -57
- package/dist/controller/runtimeEventProcessor.js +297 -58
- package/dist/controller/sessionOwnerReconciliation.js +321 -0
- package/dist/core/controllerServer.js +416 -27
- package/dist/core/controllerTelemetry.js +167 -0
- package/dist/doctor/doctor.js +113 -16
- package/dist/domain/validation.js +9 -0
- package/dist/execution/executionGroup.js +40 -3
- package/dist/executor/agentExecutor.js +6 -3
- package/dist/executor/effectiveLaunch.js +52 -0
- package/dist/executor/executorRegistry.js +50 -0
- package/dist/executor/fileRoleLaunchPlanner.js +61 -6
- package/dist/grant/capabilityGrant.js +282 -0
- package/dist/integration/changeSet.js +16 -3
- package/dist/integration/changeSetManifest.js +46 -0
- package/dist/integration/gitIntegrationService.js +528 -147
- package/dist/integration/integrationAttempt.js +54 -5
- package/dist/integration/integrationQueueEntry.js +221 -0
- package/dist/integration/integrationQueueService.js +955 -0
- package/dist/integration/manifestTags.js +99 -0
- package/dist/integration/overlapDiagnostics.js +211 -0
- package/dist/job/durableJob.js +449 -0
- package/dist/job/jobRunner.js +350 -0
- package/dist/lifecycle/exactRunTerminalization.js +24 -2
- package/dist/lifecycle/providerErrorClass.js +126 -0
- package/dist/message/message.js +16 -3
- package/dist/observability/executionAudit.js +545 -0
- package/dist/observability/faultClassification.js +160 -0
- package/dist/observability/runtimeIdentity.js +367 -0
- package/dist/release/fakeReleasePorts.js +55 -0
- package/dist/release/releaseHandover.js +475 -0
- package/dist/release/releaseIdempotencyStore.js +165 -0
- package/dist/release/releaseWorkflow.js +459 -0
- package/dist/release/releaseWorkflowEngine.js +688 -0
- package/dist/release/releaseWorkflowPorts.js +1720 -0
- package/dist/release/runtimeRelease.js +495 -0
- package/dist/release/workflowFileLock.js +218 -0
- package/dist/repository/gitWorkspace.js +177 -1
- package/dist/repository/projectMaintenanceLock.js +315 -0
- package/dist/repository/taskWorkspaceCoordinator.js +87 -17
- package/dist/repository/taskWorkspacePreparer.js +1091 -517
- package/dist/resources/autoResourceGc.js +116 -0
- package/dist/resources/liveReferences.js +574 -0
- package/dist/resources/resourceDiscovery.js +477 -0
- package/dist/resources/resourceGc.js +645 -0
- package/dist/resources/resourceRegistrar.js +256 -0
- package/dist/resources/resourceRegistry.js +150 -0
- package/dist/resources/resourceRegistryStore.js +41 -0
- package/dist/resources/resourceTypes.js +42 -0
- package/dist/resources/sqliteResourceRegistry.js +111 -0
- package/dist/review/reviewConfig.js +10 -0
- package/dist/review/reviewFinding.js +240 -0
- package/dist/review/reviewFindingLedger.js +545 -0
- package/dist/review/reviewOutcomeClassifier.js +61 -0
- package/dist/review/reviewRound.js +56 -4
- package/dist/run/agentRun.js +80 -4
- package/dist/run/providerRetry.js +84 -0
- package/dist/run/providerRetryConfig.js +63 -0
- package/dist/run/yieldReceipt.js +65 -0
- package/dist/runtime/exactControlPlane.js +79 -2
- package/dist/runtime/index.js +4 -0
- package/dist/runtime/sessionOwnerIdentity.js +269 -0
- package/dist/runtime/sessionOwnerRegistry.js +132 -0
- package/dist/runtime/sessionReconciliation.js +93 -0
- package/dist/runtime/sessionTerminationGuard.js +211 -0
- package/dist/runtime/taskRuntimeIsolation.js +13 -0
- package/dist/runtime/tmuxAdapters.js +34 -1
- package/dist/scheduler/actionability.js +155 -0
- package/dist/scheduler/activeRoleRunDelivery.js +14 -5
- package/dist/scheduler/activeTaskProgress.js +60 -0
- package/dist/scheduler/leaderWakeupProcessor.js +22 -11
- package/dist/scheduler/roleRunStall.js +135 -29
- package/dist/scheduler/taskExecutionProjection.js +11 -0
- package/dist/storage/compatibleTaskStore.js +112 -5
- package/dist/storage/migration/productionRegistry.js +736 -1
- package/dist/storage/sqliteSchema.js +264 -3
- package/dist/storage/sqliteStore.js +487 -13
- package/dist/storage/storeRpc.js +21 -0
- package/dist/storage/taskStore.js +974 -21
- package/dist/storage/upgrade/homeClassification.js +120 -2
- package/dist/storage/upgrade/migrationReceipt.js +67 -0
- package/dist/storage/upgrade/pseudoLayoutRepair.js +241 -0
- package/dist/storage/upgrade/recordVersions.js +10 -1
- package/dist/storage/upgrade/sqliteMigrationTarget.js +58 -6
- package/dist/storage/upgrade/sqliteRecordMigrationTarget.js +290 -0
- package/dist/storage/upgrade/sqliteStateMigration.js +258 -2
- package/dist/storage/upgrade/upgradeOrchestrator.js +482 -16
- package/dist/task/deliveryGuard.js +226 -0
- package/dist/task/nextAction.js +738 -0
- package/dist/task/repairWave.js +137 -0
- package/dist/task/taskRecordReference.js +6 -1
- package/dist/telemetry/sqliteTelemetryStore.js +387 -0
- package/dist/telemetry/telemetryCompaction.js +251 -0
- package/dist/telemetry/telemetryConfig.js +64 -0
- package/dist/telemetry/telemetryRouter.js +32 -0
- package/dist/telemetry/telemetryStore.js +19 -0
- package/dist/telemetry/telemetryWiring.js +33 -0
- package/dist/tmux/tmuxManager.js +20 -1
- package/dist/tmux/tmuxSocketEndpoint.js +20 -0
- package/dist/verification/gateArtifact.js +216 -0
- package/dist/verification/gateArtifactStore.js +87 -0
- package/dist/verification/verificationGateService.js +414 -0
- package/dist/verification/verificationPlan.js +308 -0
- package/dist/workspace/gitChangeSetCapture.js +12 -2
- package/dist/workspace/workItemChangeSetManager.js +60 -3
- package/package.json +1 -1
- 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 {
|
|
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
|
+
}
|
package/dist/message/message.js
CHANGED
|
@@ -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:
|
|
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 !==
|
|
28
|
-
throw new Error("Task Message must use schemaVersion
|
|
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) {
|