@zq-silk/yui 0.15.0 → 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.
Files changed (41) hide show
  1. package/README.md +33 -9
  2. package/dist/cli/commandCatalog.js +5 -5
  3. package/dist/cli.js +5 -3
  4. package/dist/commands/agentCommands.js +13 -6
  5. package/dist/commands/globalRoleCommands.js +11 -3
  6. package/dist/commands/roleConfiguration.js +7 -0
  7. package/dist/commands/roleRuntimeGuard.js +30 -0
  8. package/dist/commands/taskCommands.js +10 -3
  9. package/dist/context/sessionBootstrapManifest.js +24 -9
  10. package/dist/controller/fileSchedulerStoreAdapter.js +4 -4
  11. package/dist/controller/jobClient.js +1 -0
  12. package/dist/controller/jobControl.js +137 -10
  13. package/dist/controller/jobSupervisor.js +89 -75
  14. package/dist/controller/runtime.js +19 -28
  15. package/dist/controller/runtimeLaunchCoordinator.js +9 -30
  16. package/dist/controller/sessionNotify.js +5 -0
  17. package/dist/core/boundedRpc.js +3 -1
  18. package/dist/executor/agentExecutor.js +8 -11
  19. package/dist/executor/effectiveLaunch.js +34 -17
  20. package/dist/executor/fileRoleLaunchPlanner.js +11 -8
  21. package/dist/job/durableJob.js +68 -6
  22. package/dist/kernel/callAuthority.js +24 -0
  23. package/dist/kernel/instanceHost.js +97 -0
  24. package/dist/kernel/kernelPorts.js +44 -0
  25. package/dist/kernel/operationFacts.js +32 -0
  26. package/dist/runtime/agentHost.js +7 -0
  27. package/dist/runtime/codexInteractiveHost.js +191 -0
  28. package/dist/runtime/exactControlPlane.js +8 -10
  29. package/dist/runtime/structuredProviderHost.js +35 -0
  30. package/dist/runtime/tmuxAdapters.js +51 -9
  31. package/dist/scheduler/activeRoleTurnDelivery.js +4 -4
  32. package/dist/scheduler/leaderWakeupProcessor.js +3 -4
  33. package/dist/storage/sqliteSchema.js +28 -0
  34. package/dist/storage/sqliteStore.js +11 -3
  35. package/dist/storage/storageVersions.js +1 -1
  36. package/dist/storage/storeRpc.js +1 -1
  37. package/dist/storage/taskStore.js +6 -1
  38. package/dist/storage/upgrade/upgradeOrchestrator.js +3 -0
  39. package/dist/tmux/tmuxManager.js +43 -28
  40. package/i18n/README.zh-CN.md +7 -0
  41. package/package.json +1 -1
@@ -1,7 +1,7 @@
1
- import { createHash, randomUUID } from "node:crypto";
1
+ import { randomUUID } from "node:crypto";
2
2
  import { runtimeLifecycleTarget } from "../runtime/lifecycleReservation.js";
3
3
  import { createRuntimeBinding, RuntimeGenerationMismatchError, RuntimeHostContentionError, RuntimeLaunchError } from "../runtime/index.js";
4
- import { effectiveLaunchSnapshotsCompatible, validateEffectiveLaunchSnapshot } from "../executor/effectiveLaunch.js";
4
+ import { sameEffectiveLaunch, validateEffectiveLaunchSnapshot } from "../executor/effectiveLaunch.js";
5
5
  class RuntimeBindingContractError extends Error {
6
6
  constructor(message, options) {
7
7
  super(message, options);
@@ -25,7 +25,6 @@ export class RuntimeLaunchCoordinator {
25
25
  #createGenerationId;
26
26
  #now;
27
27
  #assertCurrent;
28
- #launchFingerprint;
29
28
  #onCleanupRequired;
30
29
  #runtimeIsolation;
31
30
  constructor(reservations, host, options = {}) {
@@ -34,8 +33,6 @@ export class RuntimeLaunchCoordinator {
34
33
  this.#createGenerationId = options.createGenerationId ?? randomUUID;
35
34
  this.#now = options.now ?? (() => new Date());
36
35
  this.#assertCurrent = options.assertCurrent;
37
- this.#launchFingerprint = options.launchFingerprint
38
- ?? defaultLaunchFingerprint;
39
36
  this.#onCleanupRequired = options.onCleanupRequired;
40
37
  this.#runtimeIsolation = options.runtimeIsolation;
41
38
  }
@@ -79,22 +76,19 @@ export class RuntimeLaunchCoordinator {
79
76
  if (request.owner.scope === "global" && request.managedWorkspace !== undefined) {
80
77
  throw new Error("A global runtime cannot use a Task ManagedWorkspace.");
81
78
  }
82
- const expectedFingerprint = requireText(this.#launchFingerprint(request), "Launch fingerprint");
83
79
  const assertLaunchCurrent = () => {
84
80
  this.#assertCurrent?.(request);
85
81
  assertCurrent?.();
86
- if (this.#launchFingerprint(request) !== expectedFingerprint) {
87
- throw new Error(`Role or Agent launch state changed: ${request.owner.roleName}.`);
88
- }
89
82
  };
90
- const generationPrefix = `runtime-${expectedFingerprint}:generation:`;
91
83
  const proposedGenerationId = requireText(this.#createGenerationId(), "Launch generation id");
92
- let proposedRuntimeGenerationId = `${generationPrefix}${proposedGenerationId}`;
84
+ // A Host activation id is an opaque durable identity, not a digest of the
85
+ // launch configuration. Whether a live activation may be reused is answered
86
+ // by the Role's durable Session record through `assertCurrent`, so launch
87
+ // configuration that only shapes the next activation never invalidates the
88
+ // current one.
89
+ let proposedRuntimeGenerationId = `runtime-${proposedGenerationId}`;
93
90
  let reusedConfirmedRunningHost = false;
94
91
  if (request.mode === "resume" && request.hostActivationId !== undefined) {
95
- if (!request.hostActivationId.startsWith(generationPrefix)) {
96
- throw new Error("Session restore targets an incompatible Host activation.");
97
- }
98
92
  const inspection = await this.host.inspectOwner(request.owner);
99
93
  if (inspection.state === "unavailable" || inspection.state === "starting") {
100
94
  throw new RuntimeLaunchError(true, request.hostActivationId, `Host activation is temporarily ${inspection.state}: ${request.owner.roleName}.`);
@@ -112,10 +106,6 @@ export class RuntimeLaunchCoordinator {
112
106
  runtimeGenerationId: proposedRuntimeGenerationId
113
107
  }, assertLaunchCurrent, this.#now());
114
108
  if (reservation.status === "existing") {
115
- if (!reservation.runtimeGenerationId.startsWith(generationPrefix)) {
116
- this.#requireCleanup(request.owner);
117
- throw new Error(`Runtime launch reservation belongs to stale Role or Agent state: ${request.owner.roleName}.`);
118
- }
119
109
  const inspection = await this.host.inspectOwner(request.owner);
120
110
  if (inspection.state === "unavailable" || inspection.state === "starting") {
121
111
  throw new RuntimeLaunchError(true, reservation.runtimeGenerationId, `Runtime is temporarily ${inspection.state}: ${request.owner.roleName}/${reservation.runtimeGenerationId}.`);
@@ -416,7 +406,7 @@ function validateRuntimeLaunchPreflight(preflight, request, runtimeGenerationId)
416
406
  || preflight.turnId !== request.turnId
417
407
  || preflight.agentId !== request.agentId
418
408
  || preflight.adapterId !== request.adapterId
419
- || !effectiveLaunchSnapshotsCompatible(preflight.effective, request.effective)
409
+ || !sameEffectiveLaunch(preflight.effective, request.effective)
420
410
  || (request.mode === "resume"
421
411
  && preflight.nativeSessionId !== request.nativeSessionId)) {
422
412
  throw new Error(`Session host pre-start launch fence does not match the requested runtime: ${request.owner.roleName}.`);
@@ -445,17 +435,6 @@ function requireMatchingRuntimeBinding(raw, request, runtimeGenerationId) {
445
435
  }
446
436
  return binding;
447
437
  }
448
- function defaultLaunchFingerprint(request) {
449
- return createHash("sha256").update(JSON.stringify([
450
- request.owner,
451
- request.agentId,
452
- request.adapterId,
453
- request.effective,
454
- request.workspace,
455
- request.managedWorkspace,
456
- request.runtimePolicy
457
- ])).digest("hex");
458
- }
459
438
  function requireText(value, label) {
460
439
  if (typeof value !== "string"
461
440
  || value.length === 0
@@ -7,6 +7,11 @@ import { openCurrentTaskStore } from "../storage/currentTaskStore.js";
7
7
  /** Hidden CLI entrypoint used by Codex's structured notify hook. */
8
8
  export async function runSessionNotifyCommand(payloadArgument, environment = process.env, call, setThreadName = setCodexThreadName) {
9
9
  const params = parseCodexSessionNotification(payloadArgument, environment);
10
+ // Old global TUIs can still emit their invocation-local hook. It is not
11
+ // evidence of a shared-daemon Thread's identity or process lifecycle.
12
+ // Global identity is committed from App Server at successful host start.
13
+ if (params.scope === "global")
14
+ return;
10
15
  const home = requireText(environment.YUI_HOME, "YUI_HOME");
11
16
  // A Codex process outlives its Turn, so the notify envelope cannot say which
12
17
  // Turn or runtime generation is current. Durable Session state answers both;
@@ -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,6 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { hasRecentTurnId, rememberRecentTurnId, validateRecentTurnIds } from "../runtime/recentTurnIds.js";
3
- import { effectiveLaunchSnapshotsCompatible, effectiveLaunchSnapshotsCompatibleForTaskSession, validateEffectiveLaunchSnapshot } from "./effectiveLaunch.js";
3
+ import { roleSessionMayContinue, validateEffectiveLaunchSnapshot } from "./effectiveLaunch.js";
4
4
  import { currentProviderActivation, endProviderActivation, settleProviderTurn, settleProviderTurnSubmission, validateProviderRuntimeBinding } from "../runtime/providerRuntimeIdentity.js";
5
5
  import { builtinDriverIdForAdapter } from "../runtime/builtinAgentDrivers.js";
6
6
  export function createRoleSessionSet(owner, activeAgentId, now) {
@@ -66,10 +66,9 @@ export function recordRoleAgentSession(set, input, now) {
66
66
  throw new Error(`Role Agent session effective identity is inconsistent: ${agentId}.`);
67
67
  }
68
68
  if (existing !== undefined && existing.nativeSessionId === nativeSessionId
69
- && !effectiveLaunchSnapshotsCompatible(existing.effective, effective)
70
- && !(set.owner.scope === "task"
71
- && effectiveLaunchSnapshotsCompatibleForTaskSession(existing.effective, effective))) {
72
- throw new Error(`Role Agent session effective launch cannot change: ${agentId}.`);
69
+ && !roleSessionMayContinue(existing.effective, effective)) {
70
+ throw new Error(`Role Agent session cannot continue under this launch: ${agentId}. `
71
+ + "Its Agent, adapter or physical workspace changed.");
73
72
  }
74
73
  if (existing !== undefined && existing.nativeSessionId !== nativeSessionId
75
74
  && existing.status === "active") {
@@ -280,13 +279,11 @@ export function roleAgentSessionResumeMode(set, agentId, desired) {
280
279
  if (session.status === "ended") {
281
280
  return "new";
282
281
  }
283
- const compatible = set.owner.scope === "task"
284
- ? effectiveLaunchSnapshotsCompatibleForTaskSession(session.effective, desired)
285
- : effectiveLaunchSnapshotsCompatible(session.effective, desired);
286
- if (compatible)
282
+ if (roleSessionMayContinue(session.effective, desired))
287
283
  return "resume";
288
- throw new Error(`Role Agent session is incompatible with the next effective launch: ${agentId}. `
289
- + "Stop the existing native process before starting a fresh Session.");
284
+ throw new Error(`Role Agent session cannot continue under the next launch: ${agentId}. `
285
+ + "Its Agent, adapter or physical workspace changed; stop the existing native "
286
+ + "process before starting a fresh Session.");
290
287
  }
291
288
  export function bindTaskRoleProviderRuntime(set, binding, updatedAt) {
292
289
  validateRoleSessionSet(set);
@@ -69,7 +69,12 @@ function claudeConfigFromSnapshot(snapshot) {
69
69
  : { settingsSources: [...snapshot.settingsSources] })
70
70
  };
71
71
  }
72
- export function effectiveLaunchSnapshotsCompatible(existing, desired) {
72
+ /**
73
+ * Exactness fence for one launch: the same resolved launch must be observed by
74
+ * every participant of that launch. Desired-revision bookkeeping is provenance
75
+ * and never part of the resolved launch itself.
76
+ */
77
+ export function sameEffectiveLaunch(existing, desired) {
73
78
  validateEffectiveLaunchSnapshot(existing);
74
79
  validateEffectiveLaunchSnapshot(desired);
75
80
  const withoutDesiredRevision = (snapshot) => {
@@ -79,20 +84,24 @@ export function effectiveLaunchSnapshotsCompatible(existing, desired) {
79
84
  return isDeepStrictEqual(withoutDesiredRevision(existing), withoutDesiredRevision(desired));
80
85
  }
81
86
  /**
82
- * Task Role Sessions keep one physical workspace while Turn-scoped facts move.
83
- * Candidate commits, ReviewRound identity and desired-revision bookkeeping do
84
- * not define a native Session. Agent, adapter, permission, model, sandbox,
85
- * manifest, Role context and physical workspace identity still do.
87
+ * Whether a live native Session can still serve the next launch request.
88
+ *
89
+ * Only facts that make continuation impossible participate: the Session
90
+ * protocol, the provider identity that owns the conversation, and the physical
91
+ * workspace the Session runs in. Launch configuration such as model, effort,
92
+ * permission, Role context, declared write scope, and Turn-scoped facts like
93
+ * ReviewRound identity or candidate commits shape the next Host activation
94
+ * instead of ending the Session; that divergence is acknowledged where the
95
+ * configuration changes and stays visible as launch provenance.
96
+ *
97
+ * Session kind needs no separate check: a Role's review Turns run in their own
98
+ * ReviewRound workspace, so the physical workspace already separates a review
99
+ * Session from an execution Session.
86
100
  */
87
- export function effectiveLaunchSnapshotsCompatibleForTaskSession(existing, desired) {
88
- if (effectiveLaunchSnapshotsCompatible(existing, desired))
89
- return true;
101
+ export function roleSessionMayContinue(existing, desired) {
90
102
  validateEffectiveLaunchSnapshot(existing);
91
103
  validateEffectiveLaunchSnapshot(desired);
92
- if ((existing.reviewRoundId === undefined) !== (desired.reviewRoundId === undefined)) {
93
- return false;
94
- }
95
- return isDeepStrictEqual(taskSessionCompatibleSnapshot(existing), taskSessionCompatibleSnapshot(desired));
104
+ return isDeepStrictEqual(sessionContinuitySnapshot(existing), sessionContinuitySnapshot(desired));
96
105
  }
97
106
  /** Preserves a fixed Session's launch configuration while freezing fresh Task-main Git facts. */
98
107
  export function effectiveLaunchWithTaskMainWorkspace(existing, workspace) {
@@ -109,13 +118,21 @@ export function effectiveLaunchWithTaskMainWorkspace(existing, workspace) {
109
118
  }
110
119
  });
111
120
  }
112
- function taskSessionCompatibleSnapshot(snapshot) {
113
- const { sourceDesiredRevision: _sourceDesiredRevision, reviewRoundId: _reviewRoundId, reviewBaseCommit: _reviewBaseCommit, workspace, ...launch } = snapshot;
121
+ function sessionContinuitySnapshot(snapshot) {
114
122
  return {
115
- ...launch,
123
+ schemaVersion: snapshot.schemaVersion,
124
+ contextProtocolVersion: snapshot.contextProtocolVersion,
125
+ agentId: snapshot.agentId,
126
+ adapterId: snapshot.adapterId,
116
127
  workspace: {
117
- root: workspace.root,
118
- entries: workspace.entries.map(({ baseCommit: _baseCommit, baseRef: _baseRef, ...entry }) => entry)
128
+ root: snapshot.workspace.root,
129
+ entries: snapshot.workspace.entries.map((entry) => ({
130
+ projectId: entry.projectId,
131
+ directory: entry.directory,
132
+ access: entry.access,
133
+ path: entry.path,
134
+ branch: entry.branch
135
+ }))
119
136
  }
120
137
  };
121
138
  }
@@ -15,7 +15,7 @@ import { resolveTaskRoleSessionTitle } from "../runtime/sessionTitle.js";
15
15
  import { nativeSessionIdForLaunch } from "../runtime/preallocatedNativeSession.js";
16
16
  import { classifyWorkspacePreflight, formatWorkspacePreflightError } from "./workspacePreflightClassification.js";
17
17
  import { activeLiveRoleAgentSession } from "./agentExecutor.js";
18
- import { effectiveLaunchSnapshotsCompatibleForTaskSession, effectiveLaunchSnapshotsCompatible, effectiveRoleForLaunch, resolveEffectiveLaunch } from "./effectiveLaunch.js";
18
+ import { roleSessionMayContinue, effectiveRoleForLaunch, resolveEffectiveLaunch } from "./effectiveLaunch.js";
19
19
  import { YUI_CONTROL_PLANE_DESCRIPTOR, createExactControlPlaneDescriptor, serializeExactDescriptor } from "../runtime/exactControlPlane.js";
20
20
  import { detectRunningRelease } from "../release/runtimeRelease.js";
21
21
  import { parseTaskRuntimeIsolationDescriptor, taskRuntimeIsolationEnvironment } from "../runtime/taskRuntimeIsolation.js";
@@ -176,9 +176,7 @@ export class FileRoleLaunchPlanner {
176
176
  const effective = input.effective ?? resolvedEffective;
177
177
  const existing = sessionSet?.sessions[effective.agentId];
178
178
  const compatibleExisting = existing !== undefined
179
- && (input.mode === "resume"
180
- ? effectiveLaunchSnapshotsCompatibleForTaskSession(existing.effective, effective)
181
- : effectiveLaunchSnapshotsCompatible(existing.effective, effective));
179
+ && roleSessionMayContinue(existing.effective, effective);
182
180
  if (input.mode === "resume" && !compatibleExisting) {
183
181
  throw new Error(`Task Role resume effective snapshot drifted: ${task.id}/${role.name}.`);
184
182
  }
@@ -204,7 +202,7 @@ export class FileRoleLaunchPlanner {
204
202
  const effective = input.effective ?? resolvedEffective;
205
203
  const existing = sessionSet?.sessions[effective.agentId];
206
204
  const compatibleExisting = existing !== undefined
207
- && effectiveLaunchSnapshotsCompatible(existing.effective, effective);
205
+ && roleSessionMayContinue(existing.effective, effective);
208
206
  if (input.mode === "resume" && !compatibleExisting) {
209
207
  throw new Error(`Global Role resume effective snapshot drifted: ${role.name}.`);
210
208
  }
@@ -246,7 +244,9 @@ export class FileRoleLaunchPlanner {
246
244
  : undefined,
247
245
  trustWorkspace: true
248
246
  });
249
- assertCodexLaunchOverridesAvailable(codexConfig, ["developerInstructions", "notify"]);
247
+ assertCodexLaunchOverridesAvailable(codexConfig, owner.scope === "global"
248
+ ? ["developerInstructions"]
249
+ : ["developerInstructions", "notify"]);
250
250
  }
251
251
  const runtimeIsolation = input.runtimeIsolation === undefined
252
252
  ? undefined
@@ -365,10 +365,10 @@ export class FileRoleLaunchPlanner {
365
365
  args.push("--plugin-dir", ensureClaudeLifecyclePlugin(this.home, this.#cliPath));
366
366
  }
367
367
  if (binding.adapterId === "codex") {
368
- // Global/interactive Codex sessions still use notify for presentation.
368
+ // Interactive Task sessions may use notify for presentation.
369
369
  // Managed Turns receive lifecycle facts through their ordinary App Server
370
370
  // subscription, avoiding a second Hook channel for the same Turn.
371
- if (owner.scope !== "task" || input.turnId === undefined) {
371
+ if (owner.scope === "task" && input.turnId === undefined) {
372
372
  args = addCodexSessionNotify(args, launchMode, this.#cliPath);
373
373
  }
374
374
  // Managed Codex Turns use disposable proxy clients against the shared
@@ -472,6 +472,9 @@ export class FileRoleLaunchPlanner {
472
472
  YUI_WORKSPACE: effectiveWorkspace,
473
473
  YUI_SESSION_MANIFEST: sessionContext.sessionManifestPath,
474
474
  YUI_SESSION_CLI: sessionContext.sessionCliPath,
475
+ ...(owner.scope === "global" && configured.adapterId === "codex"
476
+ ? { YUI_AGENT_BASE_ARGS: JSON.stringify(configured.baseArgs) }
477
+ : {}),
475
478
  ...(jobCallerKey === undefined ? {} : { YUI_JOB_CALLER_KEY: jobCallerKey }),
476
479
  ...(owner.scope !== "task"
477
480
  ? {}
@@ -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
+ }