@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 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("Exact Task control-plane invocation requires its frozen runtime descriptors.");
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 { exactControlPlaneCommandPrefix, exactControlPlaneDigest, serializeExactDescriptor } from "../runtime/exactControlPlane.js";
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 ${exactControlPlaneCommandPrefix(controlPlane)} \"$@\"`,
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. Keep the Session entry point deterministic; the
92
- // existing continuity preflight accepts compatible package replacement at
93
- // this path without treating package version as Session identity.
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 ordinary or exact two-line wrapper shapes are changed.
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 && !isExactSessionCli(content)) {
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
- function isExactSessionCli(content) {
227
- return /^#!\/bin\/sh\nexec [^\n]+ '--yui-control' '[a-f0-9]{64}' "\$@"\n$/u.test(content);
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
- validateStartParams(tx, params);
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 key = params.retryOf === undefined
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
- if (run === null || run.status !== "active") {
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 ?? "", run.effective.agentId);
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
- // f1/rr5: A cancel request on a queued job must not orphan an already-
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
- const spawnedProcess = this.#spawnedProcessFromEvidence(job, marker);
93
- if (spawnedProcess !== null) {
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
- if (marker !== null) {
104
- // f1/rr5: Ambiguous spawn — a pending marker exists but ready.json
105
- // does not. The runner may be starting (slow to write ready.json)
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 (marker === null) {
123
- this.#startJob(job, now);
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
- // f3: A pending marker means the Controller died after writing the marker
127
- // but before the runner proved it started. Check the runner's ready file.
128
- if (marker.startIdentity === "pending") {
129
- const ready = this.#artifacts.readReadyFile(job.taskId, job.id);
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
- // Marker with a real pid (already spawned). Adopt and continue.
143
- // f3/rr5: ready.json is authoritative when both exist.
144
- const spawned = this.#spawnedProcessFromEvidence(job, marker);
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. This is the only legal path from queued to a terminal state —
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
- // f3: Write a pending start marker BEFORE spawning. If the Controller dies
205
- // between this write and the spawn, the next pass sees the pending marker
206
- // and re-spawns safely (no side effects without a ready file).
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) => markDurableJobWakeupNotified(completeDurableJob(current, result, now), now), now, { reason: wakeReason("job-finished"), refs: wakeupRefs(job) });
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 { createDurableJobControl } from "./jobControl.js";
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: createLinuxProcessPort(),
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 = createDurableJobControl(store);
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 {
@@ -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) };
@@ -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
- export const CURRENT_DURABLE_JOB_SCHEMA_VERSION = 1;
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 idempotencyKey = input.retryOf === undefined
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 from running: ${job.status}.`);
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: ["queued", "running", "cancelled"],
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
- if (exactControlPlaneDigest(descriptor) !== requireDigest(input.digest)) {
84
- throw new Error("Exact control-plane digest does not match its frozen descriptor.");
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 = [];
@@ -13,4 +13,4 @@
13
13
  * intermediate Yui releases.
14
14
  */
15
15
  export const MIN_SUPPORTED_STORAGE_VERSION = 1;
16
- export const CURRENT_STORAGE_VERSION = 1;
16
+ export const CURRENT_STORAGE_VERSION = 2;
@@ -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
- constructor(message) { super(message); this.name = "StorageConflictError"; }
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))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zq-silk/yui",
3
- "version": "0.15.1",
3
+ "version": "0.15.2",
4
4
  "description": "Local control plane for long-running native agent CLI sessions backed by tmux.",
5
5
  "license": "MIT",
6
6
  "private": false,