@zq-silk/yui 0.15.1 → 0.15.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/dist/cli.js +3 -1
- package/dist/context/sessionBootstrapManifest.js +24 -9
- package/dist/controller/jobClient.js +1 -0
- package/dist/controller/jobControl.js +137 -10
- package/dist/controller/jobSupervisor.js +89 -75
- package/dist/controller/runtime.js +8 -3
- package/dist/core/boundedRpc.js +3 -1
- package/dist/job/durableJob.js +68 -6
- package/dist/kernel/callAuthority.js +24 -0
- package/dist/kernel/instanceHost.js +97 -0
- package/dist/kernel/kernelPorts.js +44 -0
- package/dist/kernel/operationFacts.js +32 -0
- package/dist/runtime/exactControlPlane.js +8 -10
- package/dist/storage/sqliteSchema.js +28 -0
- package/dist/storage/sqliteStore.js +11 -3
- package/dist/storage/storageVersions.js +1 -1
- package/dist/storage/storeRpc.js +1 -1
- package/dist/storage/taskStore.js +6 -1
- package/dist/storage/upgrade/upgradeOrchestrator.js +3 -0
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1614,7 +1614,9 @@ async function preflightManagedTaskControlPlane() {
|
|
|
1614
1614
|
}
|
|
1615
1615
|
async function preflightManagedGlobalControlPlane(digest) {
|
|
1616
1616
|
if (process.env.YUI_SESSION_SCOPE !== "global") {
|
|
1617
|
-
throw new Error("
|
|
1617
|
+
throw new Error("A control-plane digest was supplied outside a managed Session runtime. "
|
|
1618
|
+
+ "Run the command through the Role's own Session entry point, or use the "
|
|
1619
|
+
+ "ordinary yui command.");
|
|
1618
1620
|
}
|
|
1619
1621
|
if (taskFinalReviewInvocation.request !== undefined) {
|
|
1620
1622
|
throw new Error("Task final-review contract establishment requires a verified exact Task control-plane invocation.");
|
|
@@ -1,15 +1,23 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { chmodSync, existsSync, readFileSync, readdirSync } from "node:fs";
|
|
3
3
|
import { dirname, join, resolve } from "node:path";
|
|
4
|
-
import {
|
|
4
|
+
import { exactControlPlaneDigest, serializeExactDescriptor } from "../runtime/exactControlPlane.js";
|
|
5
5
|
import { writeTextFileAtomically } from "../storage/durableFile.js";
|
|
6
6
|
import { SESSION_BOOTSTRAP_MANIFEST_SCHEMA_VERSION, SESSION_CONTEXT_PROTOCOL, sessionManifestCompatibilityDigest } from "./sessionProtocolIdentity.js";
|
|
7
7
|
export { SESSION_BOOTSTRAP_MANIFEST_SCHEMA_VERSION, SESSION_CONTEXT_PROTOCOL, sessionManifestCompatibilityDigest } from "./sessionProtocolIdentity.js";
|
|
8
8
|
const ordinarySessionCli = ["#!/bin/sh", "exec yui \"$@\"", ""].join("\n");
|
|
9
|
+
/**
|
|
10
|
+
* A managed Session wrapper answers exactly one question: which installation
|
|
11
|
+
* runs this command. It therefore carries only the resolved entry point, never
|
|
12
|
+
* a package or build identity. Version identity changes on every release while
|
|
13
|
+
* a Session legitimately outlives it, so embedding it here would turn an
|
|
14
|
+
* ordinary update into a broken Session.
|
|
15
|
+
*/
|
|
9
16
|
function renderSessionCli(controlPlane) {
|
|
10
17
|
return [
|
|
11
18
|
"#!/bin/sh",
|
|
12
|
-
`exec ${
|
|
19
|
+
`exec ${quoteShellWord(controlPlane.executable)} `
|
|
20
|
+
+ `${quoteShellWord(controlPlane.cliEntry)} \"$@\"`,
|
|
13
21
|
""
|
|
14
22
|
].join("\n");
|
|
15
23
|
}
|
|
@@ -88,9 +96,10 @@ export function materializeSessionBootstrap(input) {
|
|
|
88
96
|
const descriptorPath = resolve(join(home, "runtime", "control-plane", `${controlDigest}.json`));
|
|
89
97
|
writeImmutableText(descriptorPath, `${serializeExactDescriptor(input.controlPlane)}\n`);
|
|
90
98
|
// Provider command runners may rebuild PATH independently of the managed
|
|
91
|
-
// process environment
|
|
92
|
-
//
|
|
93
|
-
//
|
|
99
|
+
// process environment, so a bare `yui` could resolve to another install or
|
|
100
|
+
// Home. The wrapper pins the resolved entry point instead. Package identity
|
|
101
|
+
// stays out of it: the compatible continuity preflight and the Session's
|
|
102
|
+
// caller key already authorize the command.
|
|
94
103
|
const sessionCliContent = renderSessionCli(input.controlPlane);
|
|
95
104
|
const sessionCliDigest = digest(sessionCliContent);
|
|
96
105
|
const sessionCliPath = resolve(join(home, "runtime", "session-cli", `yui-${sessionCliDigest}.sh`));
|
|
@@ -168,7 +177,9 @@ function quoteShellWord(value) {
|
|
|
168
177
|
/**
|
|
169
178
|
* Retargets known managed wrappers to the current resolved control plane after
|
|
170
179
|
* a compatible update. Only a valid Session Manifest may nominate a wrapper,
|
|
171
|
-
* and only Yui's
|
|
180
|
+
* and only Yui's own two-line wrapper shapes are changed. A wrapper written by
|
|
181
|
+
* an earlier release may still pin a control-plane digest; retargeting it here
|
|
182
|
+
* is what removes that stale pin from a live Home.
|
|
172
183
|
*/
|
|
173
184
|
export function refreshManagedSessionCliWrappers(homeInput, controlPlane) {
|
|
174
185
|
const home = resolve(homeInput);
|
|
@@ -213,7 +224,7 @@ export function refreshManagedSessionCliWrappers(homeInput, controlPlane) {
|
|
|
213
224
|
current += 1;
|
|
214
225
|
continue;
|
|
215
226
|
}
|
|
216
|
-
if (content !== ordinarySessionCli && !
|
|
227
|
+
if (content !== ordinarySessionCli && !isManagedSessionCli(content)) {
|
|
217
228
|
skipped += 1;
|
|
218
229
|
continue;
|
|
219
230
|
}
|
|
@@ -223,8 +234,12 @@ export function refreshManagedSessionCliWrappers(homeInput, controlPlane) {
|
|
|
223
234
|
}
|
|
224
235
|
return Object.freeze({ refreshed, current, skipped });
|
|
225
236
|
}
|
|
226
|
-
|
|
227
|
-
|
|
237
|
+
/**
|
|
238
|
+
* Both wrapper shapes Yui has written: the current entry-point-only form and
|
|
239
|
+
* the earlier form that also pinned a control-plane digest.
|
|
240
|
+
*/
|
|
241
|
+
function isManagedSessionCli(content) {
|
|
242
|
+
return /^#!\/bin\/sh\nexec [^\n]+(?: '--yui-control' '[a-f0-9]{64}')? "\$@"\n$/u.test(content);
|
|
228
243
|
}
|
|
229
244
|
function writeImmutableText(path, content) {
|
|
230
245
|
writeTextFileAtomically(path, content);
|
|
@@ -77,6 +77,7 @@ export function createControllerIntegrationJobPort(home, clientOptions = {}) {
|
|
|
77
77
|
const caller = resolveJobCaller(clientOptions.environment, input.taskId);
|
|
78
78
|
const { job } = await startDurableJob(home, {
|
|
79
79
|
taskId: input.taskId,
|
|
80
|
+
requestId: `integration:${input.integrationId}`,
|
|
80
81
|
owner,
|
|
81
82
|
projectId: input.projectId,
|
|
82
83
|
head: input.head,
|
|
@@ -12,7 +12,10 @@ import { createHash } from "node:crypto";
|
|
|
12
12
|
import { resolve, sep } from "node:path";
|
|
13
13
|
import { acknowledgeUnknownDurableJob, createDurableJob, durableJobIdempotencyKey, isDurableJobTerminal, requestDurableJobCancel, retryDurableJobIdempotencyKey } from "../job/durableJob.js";
|
|
14
14
|
import { activeLiveRoleAgentSession } from "../executor/agentExecutor.js";
|
|
15
|
+
import { CallAuthority } from "../kernel/callAuthority.js";
|
|
16
|
+
import { redactLaunchText } from "../runtime/launchDiagnostics.js";
|
|
15
17
|
export function createDurableJobControl(store) {
|
|
18
|
+
const authority = createJobCallAuthority(store);
|
|
16
19
|
return {
|
|
17
20
|
startJob(params, now) {
|
|
18
21
|
// rr4/finding-3: The entire create path — validation, idempotency
|
|
@@ -20,7 +23,8 @@ export function createDurableJobControl(store) {
|
|
|
20
23
|
// between the idempotency check and the save lets a concurrent
|
|
21
24
|
// startJob with the same key create a duplicate job.
|
|
22
25
|
return store.transaction((tx) => {
|
|
23
|
-
|
|
26
|
+
const context = authority.authenticate(Object.freeze({ ...params.caller }), params.taskId);
|
|
27
|
+
assertNonSecretJobInput(params);
|
|
24
28
|
const baseKey = durableJobIdempotencyKey({
|
|
25
29
|
owner: params.owner,
|
|
26
30
|
projectId: params.projectId,
|
|
@@ -29,12 +33,48 @@ export function createDurableJobControl(store) {
|
|
|
29
33
|
workspace: params.workspace,
|
|
30
34
|
env: params.env
|
|
31
35
|
});
|
|
32
|
-
const
|
|
36
|
+
const inputDigest = params.retryOf === undefined
|
|
33
37
|
? baseKey
|
|
34
38
|
: retryDurableJobIdempotencyKey(baseKey, params.retryOf);
|
|
39
|
+
const requestId = params.requestId === undefined ? inputDigest
|
|
40
|
+
: requiredId(params.requestId, "job.start requestId");
|
|
41
|
+
// An IntegrationAttempt already is a durable operation identity.
|
|
42
|
+
// Recovery by another authorized Role must find its original Job,
|
|
43
|
+
// including the window before Integration persisted the returned id.
|
|
44
|
+
if (params.owner.kind === "integration-attempt") {
|
|
45
|
+
const integrationId = params.owner.integrationAttemptId;
|
|
46
|
+
const owned = tx.listDurableJobs(params.taskId).filter((job) => (job.owner.kind === "integration-attempt"
|
|
47
|
+
&& job.owner.integrationAttemptId === integrationId));
|
|
48
|
+
if (owned.length > 1) {
|
|
49
|
+
throw jobDomainError("IntegrationAttempt has multiple Jobs; inspect its existing records.");
|
|
50
|
+
}
|
|
51
|
+
const original = owned[0];
|
|
52
|
+
if (original !== undefined) {
|
|
53
|
+
if (original.operation.inputDigest !== inputDigest
|
|
54
|
+
|| original.operation.targetId !== params.workspace) {
|
|
55
|
+
throw jobDomainError(`Integration Job input conflicts with its original request: ${original.id}.`);
|
|
56
|
+
}
|
|
57
|
+
return { job: original, created: false };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const key = createHash("sha256").update(JSON.stringify([
|
|
61
|
+
context.actorId, requestId
|
|
62
|
+
])).digest("hex");
|
|
35
63
|
const existing = tx.findDurableJobByIdempotencyKey(params.taskId, key);
|
|
36
|
-
if (existing !== null)
|
|
64
|
+
if (existing !== null) {
|
|
65
|
+
if (existing.operation.inputDigest !== inputDigest || existing.operation.targetId !== params.workspace) {
|
|
66
|
+
throw jobDomainError(`Job request identity conflicts with its original input: ${existing.id}.`);
|
|
67
|
+
}
|
|
37
68
|
return { job: existing, created: false };
|
|
69
|
+
}
|
|
70
|
+
// A historical content-addressed request has no attributable caller.
|
|
71
|
+
// Do not silently execute it again under a newly attributed identity.
|
|
72
|
+
const historical = tx.findDurableJobByIdempotencyKey(params.taskId, inputDigest);
|
|
73
|
+
if (params.requestId === undefined && historical !== null) {
|
|
74
|
+
throw jobDomainError(`Historical request already exists: ${historical.id}; inspect it or select an explicit new requestId.`);
|
|
75
|
+
}
|
|
76
|
+
authority.authorize(context, params.taskId);
|
|
77
|
+
validateStartParams(tx, params);
|
|
38
78
|
const id = tx.nextDurableJobId(params.taskId);
|
|
39
79
|
const job = createDurableJob({
|
|
40
80
|
id,
|
|
@@ -45,6 +85,10 @@ export function createDurableJobControl(store) {
|
|
|
45
85
|
workspace: params.workspace,
|
|
46
86
|
env: params.env,
|
|
47
87
|
steps: params.steps,
|
|
88
|
+
operation: {
|
|
89
|
+
requestId, inputDigest, actorId: context.actorId,
|
|
90
|
+
authorityRef: jobAuthorityBinding(tx, params.caller.scope, params.caller.role, params.taskId)
|
|
91
|
+
},
|
|
48
92
|
artifactsLocator: `artifacts/jobs/${params.taskId}/${id}`,
|
|
49
93
|
...(params.retryOf === undefined ? {} : { retryOf: params.retryOf })
|
|
50
94
|
}, now);
|
|
@@ -83,6 +127,78 @@ export function createDurableJobControl(store) {
|
|
|
83
127
|
}
|
|
84
128
|
};
|
|
85
129
|
}
|
|
130
|
+
/** T02 ingress adapter. The existing Job boundary remains the authority and
|
|
131
|
+
* semantic writer; this does not authorize arbitrary plugin or resource work.
|
|
132
|
+
*/
|
|
133
|
+
export function createJobCallAuthority(store) {
|
|
134
|
+
return new CallAuthority((caller, taskId) => {
|
|
135
|
+
assertCallerAuthorized(store, caller, taskId);
|
|
136
|
+
return caller.scope === "task"
|
|
137
|
+
? `task:${taskId}/role:${caller.role}`
|
|
138
|
+
: `global:${caller.role}`;
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
/** The collector does not use this gate: late results belong to the original
|
|
142
|
+
* Job even when its management binding is revoked. Only a new spawn checks it.
|
|
143
|
+
*/
|
|
144
|
+
export function authorizeJobStart(store, job) {
|
|
145
|
+
const taskPrefix = `task:${job.taskId}/role:`;
|
|
146
|
+
const actor = job.operation.actorId;
|
|
147
|
+
const scope = actor.startsWith(taskPrefix) ? "task" : actor.startsWith("global:") ? "global" : undefined;
|
|
148
|
+
if (scope === undefined)
|
|
149
|
+
throw jobDomainError("Job caller binding is unavailable; no execution was started.");
|
|
150
|
+
const role = actor.slice(scope === "task" ? taskPrefix.length : "global:".length);
|
|
151
|
+
if (jobAuthorityBinding(store, scope, role, job.taskId) !== job.operation.authorityRef) {
|
|
152
|
+
throw jobDomainError("Job caller binding was revoked; no execution was started.");
|
|
153
|
+
}
|
|
154
|
+
validateJobTarget(store, job);
|
|
155
|
+
}
|
|
156
|
+
function jobAuthorityBinding(store, scope, roleName, taskId) {
|
|
157
|
+
// A Host detach/reattach preserves the native Session and its queued work.
|
|
158
|
+
// Authenticate the live launch at ingress, but bind accepted Jobs to the
|
|
159
|
+
// caller's durable Session identity, not its disposable Host generation.
|
|
160
|
+
if (scope === "task") {
|
|
161
|
+
const role = store.getRole(taskId, roleName);
|
|
162
|
+
const sessions = store.getTaskRoleSessionSet(taskId, roleName);
|
|
163
|
+
const session = activeLiveRoleAgentSession(sessions);
|
|
164
|
+
const hash = role === null ? null : store.getJobCallerKeyHash(taskId, roleName, role.activeAgentId);
|
|
165
|
+
if (hash === null || role === null || sessions?.activeAgentId !== role.activeAgentId
|
|
166
|
+
|| session === null || session.agentId !== role.activeAgentId) {
|
|
167
|
+
throw jobDomainError("Current Job caller Session is unavailable.");
|
|
168
|
+
}
|
|
169
|
+
return createHash("sha256").update(JSON.stringify([
|
|
170
|
+
hash, session.agentId, session.adapterId, session.nativeSessionId
|
|
171
|
+
])).digest("hex");
|
|
172
|
+
}
|
|
173
|
+
const role = store.getGlobalRole(roleName);
|
|
174
|
+
const session = activeLiveRoleAgentSession(store.getGlobalRoleSessionSet(roleName));
|
|
175
|
+
if (role === null || session === null)
|
|
176
|
+
throw jobDomainError("Current Job caller binding is unavailable.");
|
|
177
|
+
return createHash("sha256").update(JSON.stringify([
|
|
178
|
+
role.activeAgentId, session.agentId, session.nativeSessionId
|
|
179
|
+
])).digest("hex");
|
|
180
|
+
}
|
|
181
|
+
function assertNonSecretJobInput(params) {
|
|
182
|
+
// Existing Jobs persist commands and environments. This boundary accepts
|
|
183
|
+
// non-secret executable specifications only; credential resolution is not a
|
|
184
|
+
// Job feature. Never hash a known secret then call the digest sanitized.
|
|
185
|
+
const secretKey = /api[_-]?key|private[_-]?key|token|secret|password|passwd|cookie|credential|authorization/i;
|
|
186
|
+
for (const env of [params.env, ...params.steps.map((step) => step.env ?? {})]) {
|
|
187
|
+
if (Object.keys(env).some((key) => secretKey.test(key))) {
|
|
188
|
+
throw jobDomainError("Job input cannot persist credentials; use a non-secret specification.");
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
const input = JSON.stringify({
|
|
192
|
+
env: params.env, steps: params.steps, requestId: params.requestId
|
|
193
|
+
});
|
|
194
|
+
// Reject recognizable key material regardless of the parameter name, and
|
|
195
|
+
// URL userinfo before either hashing or persisting the specification.
|
|
196
|
+
const privateKey = /-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY-----/;
|
|
197
|
+
const urlCredential = /[a-z][a-z0-9+.-]*:\/\/[^\s/"<>]+:[^\s/"<>]*@/i;
|
|
198
|
+
if (privateKey.test(input) || urlCredential.test(input) || redactLaunchText(input) !== input) {
|
|
199
|
+
throw jobDomainError("Job input cannot persist credentials; use a non-secret specification.");
|
|
200
|
+
}
|
|
201
|
+
}
|
|
86
202
|
/**
|
|
87
203
|
* Persisted-boundary validation for `job.start`. The owner must resolve to a
|
|
88
204
|
* live Task record, the Task must be active, the workspace must be the exact
|
|
@@ -94,6 +210,10 @@ export function createDurableJobControl(store) {
|
|
|
94
210
|
* managed workspace, verifies write access, and requires an active Task.
|
|
95
211
|
*/
|
|
96
212
|
function validateStartParams(store, params) {
|
|
213
|
+
validateJobTarget(store, params);
|
|
214
|
+
assertCallerAuthorized(store, params.caller, params.taskId);
|
|
215
|
+
}
|
|
216
|
+
function validateJobTarget(store, params) {
|
|
97
217
|
// The Task must be active — a terminal Task cannot run jobs.
|
|
98
218
|
const task = store.getTask(params.taskId);
|
|
99
219
|
if (task === null) {
|
|
@@ -179,10 +299,6 @@ function validateStartParams(store, params) {
|
|
|
179
299
|
throw jobDomainError(`Retry original job must be terminal: ${params.retryOf} is ${original.status}.`);
|
|
180
300
|
}
|
|
181
301
|
}
|
|
182
|
-
// rr8: Bind the declared owner to the caller's managed identity. A
|
|
183
|
-
// Role is not an authorization boundary. Scope and exact managed Session
|
|
184
|
-
// identity are verified independently below.
|
|
185
|
-
assertCallerAuthorized(store, params.caller, params.taskId);
|
|
186
302
|
}
|
|
187
303
|
/**
|
|
188
304
|
* rr8/rr12: Bind the declared job owner to the caller's managed identity. The
|
|
@@ -262,9 +378,17 @@ function assertCallerAuthorized(store, caller, taskId) {
|
|
|
262
378
|
// frozen environment, and its own claim would add nothing the store does
|
|
263
379
|
// not already own.
|
|
264
380
|
const run = caller.role === undefined ? null : store.getActiveTurn(taskId, caller.role);
|
|
265
|
-
|
|
381
|
+
const currentRole = caller.role === undefined ? null : store.getRole(taskId, caller.role);
|
|
382
|
+
if (run === null || run.status !== "active" || currentRole === null
|
|
383
|
+
|| currentRole.activeAgentId !== run.effective.agentId) {
|
|
266
384
|
throw jobControlError("UNAUTHORIZED", "A managed Task Session's Role is not bound to an active Turn.");
|
|
267
385
|
}
|
|
386
|
+
const sessions = store.getTaskRoleSessionSet(taskId, currentRole.name);
|
|
387
|
+
const session = activeLiveRoleAgentSession(sessions);
|
|
388
|
+
if (sessions?.activeAgentId !== currentRole.activeAgentId || session === null
|
|
389
|
+
|| session.agentId !== run.effective.agentId || session.adapterId !== run.effective.adapterId) {
|
|
390
|
+
throw jobControlError("UNAUTHORIZED", "DurableJob control requires the current live Task Session.");
|
|
391
|
+
}
|
|
268
392
|
// rr13: Verify the non-replayable per-Session caller key. The key is injected
|
|
269
393
|
// at native Session launch and never persisted in plaintext; only its SHA-256
|
|
270
394
|
// hash is durable. A client with database read access can see the hash but cannot
|
|
@@ -272,7 +396,7 @@ function assertCallerAuthorized(store, caller, taskId) {
|
|
|
272
396
|
if (caller.callerKey === undefined) {
|
|
273
397
|
throw jobControlError("UNAUTHORIZED", "job.start/job.cancel requires a managed Session caller key.");
|
|
274
398
|
}
|
|
275
|
-
const expectedHash = store.getJobCallerKeyHash(taskId, caller.role ?? "",
|
|
399
|
+
const expectedHash = store.getJobCallerKeyHash(taskId, caller.role ?? "", currentRole.activeAgentId);
|
|
276
400
|
if (expectedHash === null) {
|
|
277
401
|
throw jobControlError("UNAUTHORIZED", "The managed Session has no durable caller key; it must be relaunched.");
|
|
278
402
|
}
|
|
@@ -337,7 +461,7 @@ export function parseDurableJobStartParams(value) {
|
|
|
337
461
|
const record = value;
|
|
338
462
|
const allowed = new Set([
|
|
339
463
|
"taskId", "owner", "projectId", "head", "workspace", "env", "steps",
|
|
340
|
-
"retryOf", "caller"
|
|
464
|
+
"retryOf", "caller", "requestId"
|
|
341
465
|
]);
|
|
342
466
|
for (const key of Object.keys(record)) {
|
|
343
467
|
if (!allowed.has(key)) {
|
|
@@ -367,6 +491,9 @@ export function parseDurableJobStartParams(value) {
|
|
|
367
491
|
env,
|
|
368
492
|
steps,
|
|
369
493
|
caller,
|
|
494
|
+
...(record.requestId === undefined ? {} : {
|
|
495
|
+
requestId: requiredId(record.requestId, "job.start requestId")
|
|
496
|
+
}),
|
|
370
497
|
...(retryOf === undefined ? {} : { retryOf })
|
|
371
498
|
};
|
|
372
499
|
}
|
|
@@ -16,9 +16,10 @@ import { chmodSync, existsSync, mkdirSync, openSync, closeSync, readFileSync, st
|
|
|
16
16
|
import { join } from "node:path";
|
|
17
17
|
import { fileURLToPath } from "node:url";
|
|
18
18
|
import { writeTextFileAtomically } from "../storage/durableFile.js";
|
|
19
|
-
import { cancelQueuedDurableJob, completeDurableJob, isDurableJobTerminal, markDurableJobUnknown, markDurableJobWakeupNotified, startDurableJob, touchDurableJobHeartbeat } from "../job/durableJob.js";
|
|
19
|
+
import { cancelQueuedDurableJob, completeDurableJob, isDurableJobTerminal, markDurableJobUnknown, markDurableJobWakeupNotified, rejectQueuedDurableJob, startDurableJob, touchDurableJobHeartbeat } from "../job/durableJob.js";
|
|
20
20
|
import { readLinuxProcessStartIdentity } from "./domainIdentity.js";
|
|
21
21
|
import { wakeReason } from "../scheduler/wakeReason.js";
|
|
22
|
+
import { recordOperationEvidence } from "../kernel/operationFacts.js";
|
|
22
23
|
const DEFAULT_STEP_TIMEOUT_MS = 30 * 60_000;
|
|
23
24
|
const HEARTBEAT_STALE_MS = 2 * 60_000;
|
|
24
25
|
const SIGKILL_GRACE_MS = 30_000;
|
|
@@ -29,6 +30,7 @@ export class DurableJobSupervisor {
|
|
|
29
30
|
#terminalEvents;
|
|
30
31
|
#wake;
|
|
31
32
|
#onError;
|
|
33
|
+
#authorizeStart;
|
|
32
34
|
// f5: Composite key (taskId/jobId) because job IDs are Task-local — every
|
|
33
35
|
// Task has a job-1, so a Task-local key would cross-kill healthy runners.
|
|
34
36
|
#sigkillAt = new Map();
|
|
@@ -39,6 +41,7 @@ export class DurableJobSupervisor {
|
|
|
39
41
|
this.#terminalEvents = options.terminalEvents;
|
|
40
42
|
this.#wake = options.wake ?? (() => undefined);
|
|
41
43
|
this.#onError = options.onError ?? (() => undefined);
|
|
44
|
+
this.#authorizeStart = options.authorizeStart;
|
|
42
45
|
}
|
|
43
46
|
reconcile(now) {
|
|
44
47
|
const jobs = this.#store.listActiveDurableJobs();
|
|
@@ -67,90 +70,42 @@ export class DurableJobSupervisor {
|
|
|
67
70
|
#sigkillKey(job) {
|
|
68
71
|
return `${job.taskId}/${job.id}`;
|
|
69
72
|
}
|
|
70
|
-
/**
|
|
71
|
-
* Reconcile a queued job.
|
|
72
|
-
*
|
|
73
|
-
* f4: A queued job with a cancel request converges to `cancelled` without
|
|
74
|
-
* spawning a runner — but only if no runner was already spawned. If a
|
|
75
|
-
* start marker proves a runner exists (real pid, or pending marker +
|
|
76
|
-
* ready.json), the job is adopted to running first; the running-cancel
|
|
77
|
-
* path then fences and signals it. Cancelling a spawned job from queued
|
|
78
|
-
* would orphan the runner.
|
|
79
|
-
*
|
|
80
|
-
* f3: The normal path writes a pending start marker, spawns the runner, and
|
|
81
|
-
* lets the runner's own `ready.json` handshake prove it started before any
|
|
82
|
-
* side effect. On recovery the supervisor adopts queued→running first
|
|
83
|
-
* (harvest/unknown require `running`), then harvests exit or handles a
|
|
84
|
-
* dead process — never calling complete/unknown directly from `queued`.
|
|
85
|
-
*/
|
|
73
|
+
/** Adopt observed execution, preserve unknown, or start an unattempted request. */
|
|
86
74
|
#reconcileQueued(job, now) {
|
|
87
75
|
const marker = this.#artifacts.readStartMarker(job.taskId, job.id);
|
|
88
|
-
|
|
89
|
-
// spawned runner. Check spawn evidence before converging to cancelled.
|
|
76
|
+
const spawned = this.#spawnedProcessFromEvidence(job, marker);
|
|
90
77
|
if (job.cancelRequestedAt !== undefined) {
|
|
91
78
|
this.#artifacts.writeCancelFence(job.taskId, job.id);
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
// f1/rr5: A runner was spawned. Signal it in THIS reconcile pass —
|
|
95
|
-
// not adopt to running and wait for the next pass. The signal is
|
|
96
|
-
// sent before the adoption so the runner begins draining
|
|
97
|
-
// immediately; the adoption preserves evidence (exit.json / dead-
|
|
98
|
-
// process handling converges the job on this or the next pass).
|
|
99
|
-
this.#process.signalIfOwned(spawnedProcess.pid, spawnedProcess.startIdentity, "SIGTERM");
|
|
100
|
-
this.#adoptAndContinue(job, spawnedProcess, now);
|
|
101
|
-
return;
|
|
79
|
+
if (spawned !== null) {
|
|
80
|
+
this.#process.signalIfOwned(spawned.pid, spawned.startIdentity, "SIGTERM");
|
|
102
81
|
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
// or may never have started. Do NOT terminalize without signaling.
|
|
107
|
-
// Re-spawn: the new runner writes ready.json, observes the cancel
|
|
108
|
-
// fence, and exits as cancelled without side effects. The next
|
|
109
|
-
// pass harvests the cancelled exit.json.
|
|
110
|
-
this.#startJob(job, now);
|
|
111
|
-
return;
|
|
112
|
-
}
|
|
113
|
-
// No spawn attempted — safe to cancel from queued.
|
|
114
|
-
// f6/rr5: The terminal transition and the Leader wakeup must be
|
|
115
|
-
// atomic (same transaction). Compose cancel + wakeupNotified and
|
|
116
|
-
// pass the wakeup param so the adapter enqueues the Leader mailbox
|
|
117
|
-
// entry in the same transaction.
|
|
118
|
-
const terminal = this.#store.transitionDurableJob(job.taskId, job.id, (current) => markDurableJobWakeupNotified(cancelQueuedDurableJob(current, now), now), now, { reason: wakeReason("job-finished"), refs: wakeupRefs(job) });
|
|
119
|
-
this.#deliverTerminalEvent(terminal);
|
|
82
|
+
}
|
|
83
|
+
if (spawned !== null) {
|
|
84
|
+
this.#adoptAndContinue(job, spawned, now);
|
|
120
85
|
return;
|
|
121
86
|
}
|
|
122
|
-
if (
|
|
123
|
-
|
|
87
|
+
if (job.operation.effect !== "none") {
|
|
88
|
+
// A send intent was committed but no acceptance can be proved. Absence
|
|
89
|
+
// of a file is not proof of no external effect. Never respawn unknown.
|
|
90
|
+
const terminal = this.#store.transitionDurableJob(job.taskId, job.id, (current) => markDurableJobWakeupNotified(markDurableJobUnknown(current, "runner acceptance is unknown; inspect the original request", current.checkpoint?.completedSteps ?? [], now), now), now, { reason: wakeReason("job-finished"), refs: wakeupRefs(job) });
|
|
91
|
+
this.#deliverTerminalEvent(terminal);
|
|
124
92
|
return;
|
|
125
93
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
if (ready === null) {
|
|
131
|
-
// The runner either never started or died before writing ready.
|
|
132
|
-
// No side effects could have occurred — re-spawn safely.
|
|
133
|
-
this.#startJob(job, now);
|
|
134
|
-
return;
|
|
135
|
-
}
|
|
136
|
-
// Runner proved it started: adopt and continue from evidence.
|
|
137
|
-
// rr4/finding-4: Use the runner's own startIdentity from ready.json,
|
|
138
|
-
// not a fresh /proc read (which could return a reused PID's identity).
|
|
139
|
-
this.#adoptAndContinue(job, { pid: ready.pid, startIdentity: ready.startIdentity }, now);
|
|
94
|
+
if (job.cancelRequestedAt !== undefined) {
|
|
95
|
+
// No effect attempted. Cancellation and its wake commit atomically.
|
|
96
|
+
const terminal = this.#store.transitionDurableJob(job.taskId, job.id, (current) => markDurableJobWakeupNotified(cancelQueuedDurableJob(current, now), now), now, { reason: wakeReason("job-finished"), refs: wakeupRefs(job) });
|
|
97
|
+
this.#deliverTerminalEvent(terminal);
|
|
140
98
|
return;
|
|
141
99
|
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
this.#adoptAndContinue(job, spawned ?? { pid: marker.pid, startIdentity: marker.startIdentity }, now);
|
|
100
|
+
if (marker !== null)
|
|
101
|
+
throw new Error("Job start marker contradicts its unattempted operation.");
|
|
102
|
+
this.#startJob(job, now);
|
|
146
103
|
}
|
|
147
104
|
/**
|
|
148
105
|
* Determine whether a runner was spawned for this job, based on durable
|
|
149
106
|
* evidence. Returns the process identity if spawned, null otherwise.
|
|
150
107
|
*/
|
|
151
108
|
#spawnedProcessFromEvidence(job, marker) {
|
|
152
|
-
if (marker === null)
|
|
153
|
-
return null;
|
|
154
109
|
// f3/rr5: ready.json is the runner's own record of its actual OS start.
|
|
155
110
|
// When both the start marker and ready.json exist, ready.json is
|
|
156
111
|
// authoritative: the marker is the Controller's declared intent (written
|
|
@@ -161,7 +116,7 @@ export class DurableJobSupervisor {
|
|
|
161
116
|
if (ready !== null) {
|
|
162
117
|
return { pid: ready.pid, startIdentity: ready.startIdentity };
|
|
163
118
|
}
|
|
164
|
-
if (marker.startIdentity !== "pending") {
|
|
119
|
+
if (marker !== null && marker.startIdentity !== "pending") {
|
|
165
120
|
// Real start marker with a pid — the runner was spawned.
|
|
166
121
|
return { pid: marker.pid, startIdentity: marker.startIdentity };
|
|
167
122
|
}
|
|
@@ -170,8 +125,7 @@ export class DurableJobSupervisor {
|
|
|
170
125
|
}
|
|
171
126
|
/**
|
|
172
127
|
* f3: Adopt a queued job to running, then harvest exit or handle a dead
|
|
173
|
-
* process.
|
|
174
|
-
* complete/unknown require `running`.
|
|
128
|
+
* process. A request with unobserved acceptance instead stays unknown.
|
|
175
129
|
*/
|
|
176
130
|
#adoptAndContinue(job, process, now) {
|
|
177
131
|
this.#store.transitionDurableJob(job.taskId, job.id, (current) => startDurableJob(current, process, now), now);
|
|
@@ -190,6 +144,13 @@ export class DurableJobSupervisor {
|
|
|
190
144
|
}
|
|
191
145
|
}
|
|
192
146
|
#startJob(job, now) {
|
|
147
|
+
try {
|
|
148
|
+
this.#authorizeStart(job);
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
this.#rejectStart(job, error, now);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
193
154
|
const spec = {
|
|
194
155
|
jobId: job.id,
|
|
195
156
|
taskId: job.taskId,
|
|
@@ -201,9 +162,28 @@ export class DurableJobSupervisor {
|
|
|
201
162
|
head: job.head
|
|
202
163
|
};
|
|
203
164
|
const specPath = this.#artifacts.writeSpec(job.taskId, job.id, spec);
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
165
|
+
let attempted;
|
|
166
|
+
try {
|
|
167
|
+
attempted = this.#store.transitionDurableJob(job.taskId, job.id, (current) => {
|
|
168
|
+
if (current.status !== "queued" || current.operation.effect !== "none") {
|
|
169
|
+
throw new Error("Job already attempted; inspect the original request.");
|
|
170
|
+
}
|
|
171
|
+
this.#authorizeStart(current);
|
|
172
|
+
return {
|
|
173
|
+
...current,
|
|
174
|
+
operation: recordOperationEvidence(current.operation, { effect: "possible" }),
|
|
175
|
+
updatedAt: now.toISOString()
|
|
176
|
+
};
|
|
177
|
+
}, now);
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
this.#rejectStart(job, error, now);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (attempted === null)
|
|
184
|
+
return;
|
|
185
|
+
// Both the request and possible-effect boundary precede spawn. A missing
|
|
186
|
+
// acceptance after this point is unknown, never permission to respawn.
|
|
207
187
|
this.#artifacts.writeStartMarker(job.taskId, job.id, {
|
|
208
188
|
pid: 0,
|
|
209
189
|
startIdentity: "pending",
|
|
@@ -229,6 +209,14 @@ export class DurableJobSupervisor {
|
|
|
229
209
|
spawned.onExit?.(() => this.#wake(job.taskId));
|
|
230
210
|
this.#wake(job.taskId);
|
|
231
211
|
}
|
|
212
|
+
#rejectStart(job, error, now) {
|
|
213
|
+
// Only an explicit domain refusal is a known failure. Storage/CAS/runtime
|
|
214
|
+
// errors still propagate; never infer rejection from an unavailable read.
|
|
215
|
+
if (!(error instanceof Error) || error.name !== "CoreJobError")
|
|
216
|
+
throw error;
|
|
217
|
+
const terminal = this.#store.transitionDurableJob(job.taskId, job.id, (current) => markDurableJobWakeupNotified(rejectQueuedDurableJob(current, error.message, now), now), now, { reason: wakeReason("job-finished"), refs: wakeupRefs(job) });
|
|
218
|
+
this.#deliverTerminalEvent(terminal);
|
|
219
|
+
}
|
|
232
220
|
#superviseRunning(job, now) {
|
|
233
221
|
// 1. Harvest exit.json if present.
|
|
234
222
|
const exit = this.#artifacts.readExitJson(job.taskId, job.id);
|
|
@@ -278,6 +266,19 @@ export class DurableJobSupervisor {
|
|
|
278
266
|
}
|
|
279
267
|
}
|
|
280
268
|
#harvestExit(job, exit, now) {
|
|
269
|
+
// Persist the original receipt locator before interpreting its output.
|
|
270
|
+
// A bad schema cannot erase evidence of an already executed runner.
|
|
271
|
+
this.#store.transitionDurableJob(job.taskId, job.id, (current) => ({
|
|
272
|
+
...current,
|
|
273
|
+
operation: recordOperationEvidence(current.operation, {
|
|
274
|
+
effect: "confirmed",
|
|
275
|
+
receiptRefs: [`${current.artifactsLocator}/exit.json`],
|
|
276
|
+
partialResultRefs: Array.isArray(exit.steps)
|
|
277
|
+
? exit.steps.flatMap((step) => typeof step?.logPath === "string" && step.logPath.trim()
|
|
278
|
+
? [step.logPath] : []) : []
|
|
279
|
+
}),
|
|
280
|
+
updatedAt: now.toISOString()
|
|
281
|
+
}), now);
|
|
281
282
|
const result = {
|
|
282
283
|
outcome: exit.outcome,
|
|
283
284
|
exitCode: exit.exitCode,
|
|
@@ -291,7 +292,20 @@ export class DurableJobSupervisor {
|
|
|
291
292
|
// the adapter enqueues the Leader mailbox entry in the same transaction.
|
|
292
293
|
// A separate #notifyWakeup pass would lose the wakeup if the Controller
|
|
293
294
|
// died between the terminal write and the flag flip.
|
|
294
|
-
const terminal = this.#store.transitionDurableJob(job.taskId, job.id, (current) =>
|
|
295
|
+
const terminal = this.#store.transitionDurableJob(job.taskId, job.id, (current) => {
|
|
296
|
+
let completed;
|
|
297
|
+
try {
|
|
298
|
+
completed = completeDurableJob(current, result, now);
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
completed = completeDurableJob(current, {
|
|
302
|
+
outcome: "failed", exitCode: null, signal: null,
|
|
303
|
+
unknownReason: "runner output is invalid; original receipt is retained",
|
|
304
|
+
steps: []
|
|
305
|
+
}, now);
|
|
306
|
+
}
|
|
307
|
+
return markDurableJobWakeupNotified(completed, now);
|
|
308
|
+
}, now, { reason: wakeReason("job-finished"), refs: wakeupRefs(job) });
|
|
295
309
|
this.#deliverTerminalEvent(terminal);
|
|
296
310
|
this.#sigkillAt.delete(this.#sigkillKey(job));
|
|
297
311
|
}
|
|
@@ -21,7 +21,8 @@ import { startFileTaskController } from "./controller.js";
|
|
|
21
21
|
import { AgentHostProviderTurnFenceError, FileSchedulerStoreAdapter } from "./fileSchedulerStoreAdapter.js";
|
|
22
22
|
import { openSchedulerTelemetry } from "../telemetry/telemetryWiring.js";
|
|
23
23
|
import { createFileArtifactPort, createLinuxProcessPort, DurableJobSupervisor } from "./jobSupervisor.js";
|
|
24
|
-
import {
|
|
24
|
+
import { authorizeJobStart } from "./jobControl.js";
|
|
25
|
+
import { createKernelPorts } from "../kernel/kernelPorts.js";
|
|
25
26
|
import { FileRuntimeEventInbox } from "./runtimeEventInbox.js";
|
|
26
27
|
import { AgentRuntimeObserver } from "./agentRuntimeObserver.js";
|
|
27
28
|
import { AsyncRuntimeEventProcessor, FileRuntimeEventProcessor, createAsyncRuntimeObserver, } from "./runtimeEventProcessor.js";
|
|
@@ -355,10 +356,12 @@ export async function startFileTaskControllerRuntime(home, options = {}) {
|
|
|
355
356
|
// supervisor enqueues a durable-job-terminal event; the processor drains it
|
|
356
357
|
// on the next pass, waking the Controller immediately instead of waiting for
|
|
357
358
|
// the poll interval.
|
|
359
|
+
const kernel = createKernelPorts(store, createLinuxProcessPort());
|
|
358
360
|
const jobSupervisor = new DurableJobSupervisor({
|
|
359
361
|
store: schedulerStore,
|
|
360
|
-
process:
|
|
362
|
+
process: kernel.runner,
|
|
361
363
|
artifacts: createFileArtifactPort(home),
|
|
364
|
+
authorizeStart: (job) => authorizeJobStart(store, job),
|
|
362
365
|
// rr6/f1: Bounded supervision wake. The supervisor signals the Controller
|
|
363
366
|
// after spawning a runner (queued→running adoption) and when a runner
|
|
364
367
|
// exits (terminal harvest), so a quick job converges without waiting for
|
|
@@ -393,7 +396,7 @@ export async function startFileTaskControllerRuntime(home, options = {}) {
|
|
|
393
396
|
},
|
|
394
397
|
onError: options.onError
|
|
395
398
|
});
|
|
396
|
-
const jobControl =
|
|
399
|
+
const jobControl = kernel.jobs;
|
|
397
400
|
const continuationReconciler = options.continuationMetadata === undefined
|
|
398
401
|
? undefined
|
|
399
402
|
: new ProviderContinuationReconciliationService(store, schedulerStore, options.continuationMetadata);
|
|
@@ -462,6 +465,7 @@ export async function startFileTaskControllerRuntime(home, options = {}) {
|
|
|
462
465
|
let resourceClose;
|
|
463
466
|
const closeResources = () => {
|
|
464
467
|
resourceClose ??= Promise.all([
|
|
468
|
+
kernel.close(),
|
|
465
469
|
asyncStoreClient?.close() ?? Promise.resolve(),
|
|
466
470
|
inventoryClient?.close() ?? Promise.resolve()
|
|
467
471
|
]).then(() => undefined);
|
|
@@ -470,6 +474,7 @@ export async function startFileTaskControllerRuntime(home, options = {}) {
|
|
|
470
474
|
const closed = running.closed.then(closeResources);
|
|
471
475
|
return {
|
|
472
476
|
...running,
|
|
477
|
+
kernel,
|
|
473
478
|
closed,
|
|
474
479
|
close: async () => {
|
|
475
480
|
try {
|
package/dist/core/boundedRpc.js
CHANGED
|
@@ -33,7 +33,9 @@ export function serializeError(error) {
|
|
|
33
33
|
...(error.stack === undefined ? {} : { stack: error.stack }),
|
|
34
34
|
...("code" in error && typeof error.code === "string"
|
|
35
35
|
? { code: error.code }
|
|
36
|
-
: {})
|
|
36
|
+
: {}),
|
|
37
|
+
...("currentRevision" in error && typeof error.currentRevision === "number"
|
|
38
|
+
? { currentRevision: error.currentRevision } : {})
|
|
37
39
|
};
|
|
38
40
|
}
|
|
39
41
|
return { name: "Error", message: String(error) };
|
package/dist/job/durableJob.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { requireIdentity, requirePositiveInteger, requireText, requireTimestamp } from "../domain/validation.js";
|
|
3
3
|
import { validateTaskRecordReference } from "../task/taskRecordReference.js";
|
|
4
|
-
|
|
4
|
+
import { recordOperationEvidence, validateOperationFacts } from "../kernel/operationFacts.js";
|
|
5
|
+
export const CURRENT_DURABLE_JOB_SCHEMA_VERSION = 2;
|
|
6
|
+
export const JOB_RUNNER_IMPLEMENTATION = Object.freeze({ id: "yui:job-runner", generation: "1" });
|
|
5
7
|
export const DURABLE_JOB_TERMINAL_STATUSES = [
|
|
6
8
|
"succeeded",
|
|
7
9
|
"failed",
|
|
@@ -14,7 +16,7 @@ export function isDurableJobTerminal(status) {
|
|
|
14
16
|
}
|
|
15
17
|
export function createDurableJob(input, now) {
|
|
16
18
|
const timestamp = now.toISOString();
|
|
17
|
-
const
|
|
19
|
+
const contentKey = input.retryOf === undefined
|
|
18
20
|
? durableJobIdempotencyKey({
|
|
19
21
|
owner: input.owner,
|
|
20
22
|
projectId: input.projectId,
|
|
@@ -31,6 +33,10 @@ export function createDurableJob(input, now) {
|
|
|
31
33
|
workspace: input.workspace,
|
|
32
34
|
env: input.env
|
|
33
35
|
}), input.retryOf);
|
|
36
|
+
const idempotencyKey = input.operation === undefined ? contentKey
|
|
37
|
+
: createHash("sha256").update(JSON.stringify([
|
|
38
|
+
input.operation.actorId, input.operation.requestId
|
|
39
|
+
])).digest("hex");
|
|
34
40
|
return validateDurableJob({
|
|
35
41
|
schemaVersion: CURRENT_DURABLE_JOB_SCHEMA_VERSION,
|
|
36
42
|
id: input.id,
|
|
@@ -42,6 +48,18 @@ export function createDurableJob(input, now) {
|
|
|
42
48
|
env: { ...input.env },
|
|
43
49
|
steps: input.steps.map((step) => ({ ...step })),
|
|
44
50
|
idempotencyKey,
|
|
51
|
+
operation: {
|
|
52
|
+
requestId: input.operation?.requestId ?? idempotencyKey,
|
|
53
|
+
inputDigest: input.operation?.inputDigest ?? idempotencyKey,
|
|
54
|
+
actorId: input.operation?.actorId ?? "internal:job",
|
|
55
|
+
authorityRef: input.operation?.authorityRef ?? "internal:job",
|
|
56
|
+
targetId: input.workspace,
|
|
57
|
+
capability: "job.start",
|
|
58
|
+
implementation: JOB_RUNNER_IMPLEMENTATION,
|
|
59
|
+
effect: "none",
|
|
60
|
+
receiptRefs: [],
|
|
61
|
+
partialResultRefs: []
|
|
62
|
+
},
|
|
45
63
|
status: "queued",
|
|
46
64
|
artifactsLocator: input.artifactsLocator,
|
|
47
65
|
createdAt: timestamp,
|
|
@@ -58,6 +76,10 @@ export function startDurableJob(job, process, now) {
|
|
|
58
76
|
return validateDurableJob({
|
|
59
77
|
...job,
|
|
60
78
|
status: "running",
|
|
79
|
+
operation: recordOperationEvidence(job.operation, {
|
|
80
|
+
effect: "confirmed",
|
|
81
|
+
receiptRefs: [`${job.artifactsLocator}/start.json`]
|
|
82
|
+
}),
|
|
61
83
|
process: { ...process },
|
|
62
84
|
startedAt: timestamp,
|
|
63
85
|
heartbeatAt: timestamp,
|
|
@@ -87,20 +109,46 @@ export function completeDurableJob(job, result, now) {
|
|
|
87
109
|
return validateDurableJob({
|
|
88
110
|
...job,
|
|
89
111
|
status: result.outcome,
|
|
112
|
+
operation: recordOperationEvidence(job.operation, {
|
|
113
|
+
partialResultRefs: result.steps.map((step) => step.logPath),
|
|
114
|
+
receiptRefs: result.evidenceSource === undefined ? []
|
|
115
|
+
: [`${job.artifactsLocator}/${result.evidenceSource === "checkpoint" ? "checkpoint.json" : "exit.json"}`]
|
|
116
|
+
}),
|
|
90
117
|
result: normalizeDurableJobResult(result),
|
|
91
118
|
terminalAt: timestamp,
|
|
92
119
|
updatedAt: timestamp
|
|
93
120
|
});
|
|
94
121
|
}
|
|
122
|
+
/** A refused, unattempted request has a known failed outcome, not an unknown effect. */
|
|
123
|
+
export function rejectQueuedDurableJob(job, reason, now) {
|
|
124
|
+
validateDurableJob(job);
|
|
125
|
+
if (job.status !== "queued" || job.operation.effect !== "none") {
|
|
126
|
+
throw new Error("Only an unattempted queued Job can be rejected.");
|
|
127
|
+
}
|
|
128
|
+
const timestamp = now.toISOString();
|
|
129
|
+
return validateDurableJob({
|
|
130
|
+
...job,
|
|
131
|
+
status: "failed",
|
|
132
|
+
result: {
|
|
133
|
+
outcome: "failed", exitCode: null, signal: null,
|
|
134
|
+
unknownReason: requireText(reason, "DurableJob rejection reason"), steps: []
|
|
135
|
+
},
|
|
136
|
+
terminalAt: timestamp,
|
|
137
|
+
updatedAt: timestamp
|
|
138
|
+
});
|
|
139
|
+
}
|
|
95
140
|
export function markDurableJobUnknown(job, unknownReason, completedSteps, now) {
|
|
96
141
|
validateDurableJob(job);
|
|
97
|
-
if (job.status !== "running") {
|
|
98
|
-
throw new Error(`DurableJob can only be marked unknown
|
|
142
|
+
if (job.status !== "running" && !(job.status === "queued" && job.operation.effect !== "none")) {
|
|
143
|
+
throw new Error(`DurableJob can only be marked unknown after an attempted effect: ${job.status}.`);
|
|
99
144
|
}
|
|
100
145
|
const timestamp = now.toISOString();
|
|
101
146
|
return validateDurableJob({
|
|
102
147
|
...job,
|
|
103
148
|
status: "unknown-needs-attention",
|
|
149
|
+
operation: recordOperationEvidence(job.operation, {
|
|
150
|
+
partialResultRefs: completedSteps.map((step) => step.logPath)
|
|
151
|
+
}),
|
|
104
152
|
result: {
|
|
105
153
|
outcome: "unknown-needs-attention",
|
|
106
154
|
exitCode: null,
|
|
@@ -205,6 +253,7 @@ export function validateDurableJob(job) {
|
|
|
205
253
|
if (job.schemaVersion !== CURRENT_DURABLE_JOB_SCHEMA_VERSION) {
|
|
206
254
|
throw new Error(`DurableJob must use schemaVersion ${CURRENT_DURABLE_JOB_SCHEMA_VERSION}.`);
|
|
207
255
|
}
|
|
256
|
+
validateOperationFacts(job.operation);
|
|
208
257
|
validateTaskRecordReference({
|
|
209
258
|
taskId: job.taskId,
|
|
210
259
|
localId: job.id
|
|
@@ -326,7 +375,15 @@ export function validDurableJobTransition(before, after) {
|
|
|
326
375
|
|| before.createdAt !== after.createdAt
|
|
327
376
|
|| !isDeepStrictEqual(before.owner, after.owner)
|
|
328
377
|
|| !isDeepStrictEqual(before.env, after.env)
|
|
329
|
-
|| !isDeepStrictEqual(before.steps, after.steps)
|
|
378
|
+
|| !isDeepStrictEqual(before.steps, after.steps)
|
|
379
|
+
|| before.operation.requestId !== after.operation.requestId
|
|
380
|
+
|| before.operation.inputDigest !== after.operation.inputDigest
|
|
381
|
+
|| before.operation.actorId !== after.operation.actorId
|
|
382
|
+
|| before.operation.authorityRef !== after.operation.authorityRef
|
|
383
|
+
|| before.operation.targetId !== after.operation.targetId
|
|
384
|
+
|| before.operation.capability !== after.operation.capability
|
|
385
|
+
|| !isDeepStrictEqual(before.operation.implementation, after.operation.implementation)
|
|
386
|
+
|| !isDeepStrictEqual(recordOperationEvidence(before.operation, after.operation), after.operation))
|
|
330
387
|
return false;
|
|
331
388
|
if (isDurableJobTerminal(before.status)) {
|
|
332
389
|
// A terminal job is immutable except for two one-way flags:
|
|
@@ -340,12 +397,17 @@ export function validDurableJobTransition(before, after) {
|
|
|
340
397
|
&& after.acknowledgedAt !== undefined;
|
|
341
398
|
return before.status === after.status
|
|
342
399
|
&& isDeepStrictEqual(before.result, after.result)
|
|
400
|
+
&& isDeepStrictEqual(before.operation, after.operation)
|
|
343
401
|
&& before.terminalAt === after.terminalAt
|
|
344
402
|
&& (before.wakeupNotified === after.wakeupNotified || wakeupFlip)
|
|
345
403
|
&& (before.acknowledgedAt === after.acknowledgedAt || acknowledgeFlip);
|
|
346
404
|
}
|
|
347
405
|
const allowed = {
|
|
348
|
-
queued: [
|
|
406
|
+
queued: [
|
|
407
|
+
"queued", "running", "cancelled", "unknown-needs-attention",
|
|
408
|
+
...(before.operation.effect === "none" && after.operation.effect === "none"
|
|
409
|
+
? ["failed"] : [])
|
|
410
|
+
],
|
|
349
411
|
running: [
|
|
350
412
|
"running",
|
|
351
413
|
"succeeded",
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export class CallAuthority {
|
|
2
|
+
authenticateCurrent;
|
|
3
|
+
#credentials = new WeakMap();
|
|
4
|
+
constructor(authenticateCurrent) {
|
|
5
|
+
this.authenticateCurrent = authenticateCurrent;
|
|
6
|
+
}
|
|
7
|
+
authenticate(credential, targetId) {
|
|
8
|
+
const actorId = this.authenticateCurrent(credential, targetId);
|
|
9
|
+
const context = Object.freeze({ actorId, targetId });
|
|
10
|
+
this.#credentials.set(context, credential);
|
|
11
|
+
return context;
|
|
12
|
+
}
|
|
13
|
+
/** Reauthenticate at each new action; a context or implementation handle is
|
|
14
|
+
* not a permanent grant. Domain-specific authorization remains in its owner.
|
|
15
|
+
*/
|
|
16
|
+
authorize(context, targetId) {
|
|
17
|
+
if (!this.#credentials.has(context) || context.targetId !== targetId) {
|
|
18
|
+
throw new Error("Untrusted or out-of-scope call context.");
|
|
19
|
+
}
|
|
20
|
+
const actorId = this.authenticateCurrent(this.#credentials.get(context), targetId);
|
|
21
|
+
if (actorId !== context.actorId)
|
|
22
|
+
throw new Error("Call authority changed.");
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
export class InstanceHost {
|
|
2
|
+
#instances = new Map();
|
|
3
|
+
#closed = false;
|
|
4
|
+
attach(implementation, value, ownedDisposers = []) {
|
|
5
|
+
if (this.#closed)
|
|
6
|
+
throw new Error("Instance Host is closed.");
|
|
7
|
+
const key = implementationKey(implementation);
|
|
8
|
+
if (this.#instances.has(key))
|
|
9
|
+
throw new Error(`Implementation already attached: ${key}.`);
|
|
10
|
+
let resolve;
|
|
11
|
+
let reject;
|
|
12
|
+
const drained = new Promise((yes, no) => { resolve = yes; reject = no; });
|
|
13
|
+
// A disposer can fail before detach's caller starts awaiting the drain.
|
|
14
|
+
void drained.catch(() => undefined);
|
|
15
|
+
const ref = Object.freeze({ ...implementation });
|
|
16
|
+
this.#instances.set(key, {
|
|
17
|
+
implementation: ref, value, references: 0, detached: false,
|
|
18
|
+
disposers: [...ownedDisposers], drained, resolve, reject
|
|
19
|
+
});
|
|
20
|
+
return ref;
|
|
21
|
+
}
|
|
22
|
+
acquire(implementation) {
|
|
23
|
+
const instance = this.#instances.get(implementationKey(implementation));
|
|
24
|
+
if (this.#closed || instance === undefined || instance.detached) {
|
|
25
|
+
throw new Error("Implementation unavailable.");
|
|
26
|
+
}
|
|
27
|
+
instance.references += 1;
|
|
28
|
+
let released = false;
|
|
29
|
+
return Object.freeze({
|
|
30
|
+
implementation: instance.implementation,
|
|
31
|
+
value: instance.value,
|
|
32
|
+
release: async () => {
|
|
33
|
+
if (released)
|
|
34
|
+
return;
|
|
35
|
+
released = true;
|
|
36
|
+
instance.references -= 1;
|
|
37
|
+
if (instance.detached && instance.references === 0)
|
|
38
|
+
await this.#dispose(instance);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
async use(implementation, call) {
|
|
43
|
+
const handle = this.acquire(implementation);
|
|
44
|
+
try {
|
|
45
|
+
return await call(handle.value, handle.implementation);
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
await handle.release();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** Stops acquisition immediately. Resolves only after all calls/Sessions release. */
|
|
52
|
+
detach(implementation) {
|
|
53
|
+
const instance = this.#instances.get(implementationKey(implementation));
|
|
54
|
+
if (instance === undefined)
|
|
55
|
+
throw new Error("Implementation is not attached.");
|
|
56
|
+
instance.detached = true;
|
|
57
|
+
if (instance.references === 0)
|
|
58
|
+
void this.#dispose(instance).catch(() => undefined);
|
|
59
|
+
return instance.drained;
|
|
60
|
+
}
|
|
61
|
+
async close() {
|
|
62
|
+
this.#closed = true;
|
|
63
|
+
const results = await Promise.allSettled([...this.#instances.values()].map((instance) => this.detach(instance.implementation)));
|
|
64
|
+
const errors = results.flatMap((result) => result.status === "rejected" ? [result.reason] : []);
|
|
65
|
+
if (errors.length)
|
|
66
|
+
throw new AggregateError(errors, "Instance cleanup failed.");
|
|
67
|
+
}
|
|
68
|
+
#dispose(instance) {
|
|
69
|
+
instance.disposal ??= (async () => {
|
|
70
|
+
const errors = [];
|
|
71
|
+
for (const dispose of [...instance.disposers].reverse()) {
|
|
72
|
+
try {
|
|
73
|
+
await dispose();
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
errors.push(error);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// Keep the identity reserved for this Host's lifetime, including failures.
|
|
80
|
+
// Re-attaching a generation must not resurrect an old reference.
|
|
81
|
+
instance.value = undefined;
|
|
82
|
+
instance.disposers = [];
|
|
83
|
+
if (errors.length)
|
|
84
|
+
throw new AggregateError(errors, "Instance cleanup failed.");
|
|
85
|
+
})();
|
|
86
|
+
void instance.disposal.then(instance.resolve, instance.reject);
|
|
87
|
+
return instance.disposal;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function implementationKey(ref) {
|
|
91
|
+
for (const value of [ref.id, ref.generation]) {
|
|
92
|
+
if (typeof value !== "string" || !value.trim() || value.includes("\0")) {
|
|
93
|
+
throw new Error("Implementation identity is invalid.");
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return JSON.stringify([ref.id, ref.generation]);
|
|
97
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { createDurableJobControl } from "../controller/jobControl.js";
|
|
2
|
+
import { JOB_RUNNER_IMPLEMENTATION } from "../job/durableJob.js";
|
|
3
|
+
import { InstanceHost } from "./instanceHost.js";
|
|
4
|
+
/** Called once by the existing Controller root. Does not open a Store, start
|
|
5
|
+
* another Controller, or provide arbitrary persistence to plugin code.
|
|
6
|
+
* T02 registers contributions on this Host and wraps this same Job control.
|
|
7
|
+
*/
|
|
8
|
+
export function createKernelPorts(store, runner) {
|
|
9
|
+
const host = new InstanceHost();
|
|
10
|
+
const runnerImplementation = host.attach(JOB_RUNNER_IMPLEMENTATION, runner);
|
|
11
|
+
const runnerHandle = host.acquire(runnerImplementation);
|
|
12
|
+
const jobImplementation = host.attach({ id: "yui:job-control", generation: "1" }, createDurableJobControl(store));
|
|
13
|
+
// The Controller is a long-lived consumer of this exact implementation.
|
|
14
|
+
const jobHandle = host.acquire(jobImplementation);
|
|
15
|
+
return {
|
|
16
|
+
host,
|
|
17
|
+
jobImplementation,
|
|
18
|
+
runnerImplementation,
|
|
19
|
+
runner: runnerHandle.value,
|
|
20
|
+
jobs: jobHandle.value,
|
|
21
|
+
close: async () => {
|
|
22
|
+
const drain = host.close();
|
|
23
|
+
await jobHandle.release();
|
|
24
|
+
await runnerHandle.release();
|
|
25
|
+
await drain;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/** A read model over the one Job, never separately persisted.
|
|
30
|
+
* confirmed means the selected runner was observed, not that every action
|
|
31
|
+
* performed by an arbitrary command succeeded. Inspect original step receipts.
|
|
32
|
+
*/
|
|
33
|
+
export function inspectJobOperation(job) {
|
|
34
|
+
return {
|
|
35
|
+
operationRef: { taskId: job.taskId, jobId: job.id },
|
|
36
|
+
...job.operation,
|
|
37
|
+
state: job.status === "queued" ? "pending"
|
|
38
|
+
: job.status === "running" ? "running"
|
|
39
|
+
: job.status === "unknown-needs-attention" ? "unknown" : "finished",
|
|
40
|
+
outcome: job.result?.outcome,
|
|
41
|
+
result: job.result,
|
|
42
|
+
checkpoint: job.checkpoint
|
|
43
|
+
};
|
|
44
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export function validateOperationFacts(facts) {
|
|
2
|
+
for (const value of [
|
|
3
|
+
facts?.requestId, facts?.actorId, facts?.authorityRef, facts?.targetId, facts?.capability,
|
|
4
|
+
facts?.implementation?.id, facts?.implementation?.generation
|
|
5
|
+
]) {
|
|
6
|
+
if (typeof value !== "string" || !value.trim() || value.includes("\0")) {
|
|
7
|
+
throw new Error("Operation identity is invalid.");
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
if (!/^[a-f0-9]{64}$/u.test(facts.inputDigest))
|
|
11
|
+
throw new Error("Operation input digest is invalid.");
|
|
12
|
+
if (!["none", "possible", "confirmed"].includes(facts.effect))
|
|
13
|
+
throw new Error("Operation effect is invalid.");
|
|
14
|
+
for (const refs of [facts.receiptRefs, facts.partialResultRefs]) {
|
|
15
|
+
if (!Array.isArray(refs) || refs.some((ref) => typeof ref !== "string" || !ref.trim())) {
|
|
16
|
+
throw new Error("Operation evidence references are invalid.");
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/** Effects only accumulate. Later output failure or cancellation cannot erase evidence. */
|
|
21
|
+
export function recordOperationEvidence(facts, evidence) {
|
|
22
|
+
const rank = { none: 0, possible: 1, confirmed: 2 };
|
|
23
|
+
const next = {
|
|
24
|
+
...facts,
|
|
25
|
+
effect: evidence.effect !== undefined && rank[evidence.effect] > rank[facts.effect]
|
|
26
|
+
? evidence.effect : facts.effect,
|
|
27
|
+
receiptRefs: [...new Set([...facts.receiptRefs, ...(evidence.receiptRefs ?? [])])],
|
|
28
|
+
partialResultRefs: [...new Set([...facts.partialResultRefs, ...(evidence.partialResultRefs ?? [])])]
|
|
29
|
+
};
|
|
30
|
+
validateOperationFacts(next);
|
|
31
|
+
return next;
|
|
32
|
+
}
|
|
@@ -44,14 +44,6 @@ export function parseExactControlPlaneDescriptor(value) {
|
|
|
44
44
|
export function exactControlPlaneDigest(descriptor) {
|
|
45
45
|
return createHash("sha256").update(serializeExactDescriptor(descriptor)).digest("hex");
|
|
46
46
|
}
|
|
47
|
-
export function exactControlPlaneCommandPrefix(descriptor) {
|
|
48
|
-
return [
|
|
49
|
-
descriptor.executable,
|
|
50
|
-
descriptor.cliEntry,
|
|
51
|
-
EXACT_CONTROL_ARGUMENT,
|
|
52
|
-
exactControlPlaneDigest(descriptor)
|
|
53
|
-
].map(shellQuote).join(" ");
|
|
54
|
-
}
|
|
55
47
|
export function extractExactControlArgument(args) {
|
|
56
48
|
const later = args.indexOf(EXACT_CONTROL_ARGUMENT);
|
|
57
49
|
if (later < 0)
|
|
@@ -80,8 +72,14 @@ export function extractExactControlArgument(args) {
|
|
|
80
72
|
*/
|
|
81
73
|
export async function assertExactControlPlanePreflight(input, options = {}) {
|
|
82
74
|
const descriptor = parseExactControlPlaneDescriptor(input.serializedDescriptor);
|
|
83
|
-
|
|
84
|
-
|
|
75
|
+
const frozenDigest = exactControlPlaneDigest(descriptor);
|
|
76
|
+
const requestedDigest = requireDigest(input.digest);
|
|
77
|
+
if (frozenDigest !== requestedDigest) {
|
|
78
|
+
throw new Error("Exact control-plane invocation names another runtime than this Session's frozen "
|
|
79
|
+
+ `descriptor (requested ${requestedDigest}, Session ${frozenDigest}). `
|
|
80
|
+
+ "Yui no longer pins package identity into a Session entry point: invoke the "
|
|
81
|
+
+ "ordinary command for this Session, or start a new Session when the Session "
|
|
82
|
+
+ "itself must move to a different runtime.");
|
|
85
83
|
}
|
|
86
84
|
assertSamePath(descriptor.executable, input.actualExecutable, "Control-plane executable");
|
|
87
85
|
assertSamePath(descriptor.cliEntry, input.actualCliEntry, "Control-plane CLI entry");
|
|
@@ -660,6 +660,34 @@ const MIGRATIONS = Object.freeze([
|
|
|
660
660
|
name: "v0.15.0-baseline",
|
|
661
661
|
introducedIn: "0.15.0",
|
|
662
662
|
sql: MIGRATION_1_SQL
|
|
663
|
+
},
|
|
664
|
+
{
|
|
665
|
+
version: 2,
|
|
666
|
+
name: "job-operation-facts",
|
|
667
|
+
introducedIn: "0.15.2",
|
|
668
|
+
// Historical records never carried caller identity or external effect
|
|
669
|
+
// evidence. Preserve that uncertainty rather than inventing attribution.
|
|
670
|
+
// No old executable implementation or runtime dual-read is needed.
|
|
671
|
+
sql: `
|
|
672
|
+
UPDATE durable_jobs SET payload = json_set(payload,
|
|
673
|
+
'$.schemaVersion', 2,
|
|
674
|
+
'$.operation', json_object(
|
|
675
|
+
'requestId', json_extract(payload, '$.idempotencyKey'),
|
|
676
|
+
'inputDigest', json_extract(payload, '$.idempotencyKey'),
|
|
677
|
+
'actorId', 'historical:unrecorded',
|
|
678
|
+
'authorityRef', 'historical:unrecorded',
|
|
679
|
+
'targetId', json_extract(payload, '$.workspace'),
|
|
680
|
+
'capability', 'job.start',
|
|
681
|
+
'implementation', json_object('id', 'yui:job-runner', 'generation', '1'),
|
|
682
|
+
'effect', 'possible',
|
|
683
|
+
'receiptRefs', json('[]'),
|
|
684
|
+
'partialResultRefs', json('[]')
|
|
685
|
+
)
|
|
686
|
+
);
|
|
687
|
+
CREATE UNIQUE INDEX idx_durable_jobs_request
|
|
688
|
+
ON durable_jobs(task_id, json_extract(payload, '$.operation.actorId'),
|
|
689
|
+
json_extract(payload, '$.operation.requestId'));
|
|
690
|
+
`
|
|
663
691
|
}
|
|
664
692
|
]);
|
|
665
693
|
for (let index = 0; index < MIGRATIONS.length; index += 1) {
|
|
@@ -314,13 +314,18 @@ export class SqliteTaskStore {
|
|
|
314
314
|
* and the increment happen in the same write transaction.
|
|
315
315
|
*/
|
|
316
316
|
transactionWithRevisionCas(expectedRevision, execute, options) {
|
|
317
|
-
if (this.#inTransaction)
|
|
317
|
+
if (this.#inTransaction) {
|
|
318
|
+
const current = this.getRevision();
|
|
319
|
+
if (current !== expectedRevision) {
|
|
320
|
+
throw new StorageConflictError(`Storage revision conflict (expected ${expectedRevision}, found ${current}).`, current);
|
|
321
|
+
}
|
|
318
322
|
return execute(this);
|
|
323
|
+
}
|
|
319
324
|
this.#begin();
|
|
320
325
|
try {
|
|
321
326
|
const current = this.getRevision();
|
|
322
327
|
if (current !== expectedRevision) {
|
|
323
|
-
throw new StorageConflictError(`Storage revision conflict (expected ${expectedRevision}, found ${current})
|
|
328
|
+
throw new StorageConflictError(`Storage revision conflict (expected ${expectedRevision}, found ${current}).`, current);
|
|
324
329
|
}
|
|
325
330
|
const result = execute(this);
|
|
326
331
|
if (this.#dirty) {
|
|
@@ -363,6 +368,9 @@ export class SqliteTaskStore {
|
|
|
363
368
|
if (this.#inTransaction) {
|
|
364
369
|
// Nested inside a synchronous transaction: run without yielding (the
|
|
365
370
|
// caller already holds the write lock).
|
|
371
|
+
if (options.expectedRevision !== undefined && this.getRevision() !== options.expectedRevision) {
|
|
372
|
+
throw new StorageConflictError("Storage revision conflict.", this.getRevision());
|
|
373
|
+
}
|
|
366
374
|
return commands.map((command) => this.#executeCommand(command.op, command.args));
|
|
367
375
|
}
|
|
368
376
|
this.#begin();
|
|
@@ -372,7 +380,7 @@ export class SqliteTaskStore {
|
|
|
372
380
|
if (options.expectedRevision !== undefined) {
|
|
373
381
|
const current = this.getRevision();
|
|
374
382
|
if (current !== options.expectedRevision) {
|
|
375
|
-
throw new StorageConflictError(`Storage revision conflict (expected ${options.expectedRevision}, found ${current})
|
|
383
|
+
throw new StorageConflictError(`Storage revision conflict (expected ${options.expectedRevision}, found ${current}).`, current);
|
|
376
384
|
}
|
|
377
385
|
}
|
|
378
386
|
const results = [];
|
package/dist/storage/storeRpc.js
CHANGED
|
@@ -105,7 +105,7 @@ function isReadOnlyMethod(method) {
|
|
|
105
105
|
function deserializeError(serialized) {
|
|
106
106
|
const { name, message } = serialized;
|
|
107
107
|
if (name === "StorageConflictError")
|
|
108
|
-
return new StorageConflictError(message);
|
|
108
|
+
return new StorageConflictError(message, serialized.currentRevision);
|
|
109
109
|
if (name === "StorageRecordError")
|
|
110
110
|
return new StorageRecordError(message);
|
|
111
111
|
if (name === "StorageCancelledError" || name === "AbortError") {
|
|
@@ -72,7 +72,12 @@ export class StorageRecordError extends Error {
|
|
|
72
72
|
constructor(message) { super(message); this.name = "StorageRecordError"; }
|
|
73
73
|
}
|
|
74
74
|
export class StorageConflictError extends Error {
|
|
75
|
-
|
|
75
|
+
currentRevision;
|
|
76
|
+
constructor(message, currentRevision) {
|
|
77
|
+
super(message);
|
|
78
|
+
this.currentRevision = currentRevision;
|
|
79
|
+
this.name = "StorageConflictError";
|
|
80
|
+
}
|
|
76
81
|
}
|
|
77
82
|
/**
|
|
78
83
|
* Raised by the persistence worker when an `AbortSignal` cancels an in-flight
|
|
@@ -7,6 +7,7 @@ import { validateAgentProfile } from "../../profile/agentProfile.js";
|
|
|
7
7
|
import { validateRoleSessionSet } from "../../executor/agentExecutor.js";
|
|
8
8
|
import { validateReviewRound } from "../../review/reviewRound.js";
|
|
9
9
|
import { validateTurn } from "../../turn/turn.js";
|
|
10
|
+
import { validateDurableJob } from "../../job/durableJob.js";
|
|
10
11
|
import { validateWorkItem } from "../../workItem/workItem.js";
|
|
11
12
|
import { SqliteTaskStore } from "../sqliteStore.js";
|
|
12
13
|
import { migrateSqliteSchema, storageMigrationPlan } from "../sqliteSchema.js";
|
|
@@ -191,6 +192,8 @@ function validateCurrentStore(home) {
|
|
|
191
192
|
}
|
|
192
193
|
}
|
|
193
194
|
for (const taskId of store.listTasks().map(({ id }) => id)) {
|
|
195
|
+
for (const job of store.listDurableJobs(taskId))
|
|
196
|
+
validateDurableJob(job);
|
|
194
197
|
for (const item of store.listWorkItems(taskId))
|
|
195
198
|
validateWorkItem(item);
|
|
196
199
|
for (const round of store.listReviewRounds(taskId))
|