@zq-silk/yui 0.14.2 → 0.15.1

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 (44) hide show
  1. package/ARCHITECTURE.md +27 -12
  2. package/README.md +85 -61
  3. package/dist/cli/commandCatalog.js +6 -6
  4. package/dist/cli/updateCommand.js +17 -9
  5. package/dist/cli/updateOrchestrator.js +81 -15
  6. package/dist/cli/updatePorts.js +72 -10
  7. package/dist/cli/upgradeCommand.js +104 -19
  8. package/dist/cli.js +2 -2
  9. package/dist/commands/agentCommands.js +13 -6
  10. package/dist/commands/controllerCommands.js +1 -1
  11. package/dist/commands/globalRoleCommands.js +11 -3
  12. package/dist/commands/roleConfiguration.js +7 -0
  13. package/dist/commands/roleRuntimeGuard.js +30 -0
  14. package/dist/commands/taskCommands.js +10 -3
  15. package/dist/controller/fileSchedulerStoreAdapter.js +4 -4
  16. package/dist/controller/runtime.js +11 -25
  17. package/dist/controller/runtimeLaunchCoordinator.js +9 -30
  18. package/dist/controller/sessionNotify.js +5 -0
  19. package/dist/core/controllerServer.js +5 -5
  20. package/dist/doctor/doctor.js +37 -14
  21. package/dist/executor/agentExecutor.js +8 -11
  22. package/dist/executor/effectiveLaunch.js +34 -17
  23. package/dist/executor/fileRoleLaunchPlanner.js +11 -8
  24. package/dist/observability/runtimeIdentity.js +48 -50
  25. package/dist/release/runtimeRelease.js +9 -1
  26. package/dist/runtime/agentHost.js +7 -0
  27. package/dist/runtime/codexInteractiveHost.js +191 -0
  28. package/dist/runtime/exactControlPlane.js +20 -29
  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/currentTaskStore.js +6 -4
  34. package/dist/storage/sqliteSchema.js +134 -59
  35. package/dist/storage/sqliteStore.js +7 -5
  36. package/dist/storage/storageSchema.js +92 -223
  37. package/dist/storage/storageVersions.js +12 -16
  38. package/dist/storage/upgrade/upgradeOrchestrator.js +224 -62
  39. package/dist/tmux/tmuxManager.js +43 -28
  40. package/dist/version.js +3 -3
  41. package/docs/task-local-identity.md +9 -9
  42. package/i18n/README.zh-CN.md +33 -17
  43. package/package.json +1 -1
  44. package/dist/storage/upgrade/recordVersions.js +0 -82
@@ -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;
@@ -400,8 +400,8 @@ async function routeRequest(socket, line, token, dispatcher, stop, status, telem
400
400
  homeFilesystemId,
401
401
  controllerInstanceId,
402
402
  version: YUI_VERSION,
403
- storageLayoutVersion: yuiVersionIdentity().storageLayoutVersion,
404
- aggregateSchemaVersion: yuiVersionIdentity().aggregateSchemaVersion,
403
+ storageVersion: yuiVersionIdentity().storageVersion,
404
+ minimumStorageVersion: yuiVersionIdentity().minimumStorageVersion,
405
405
  ...(status === undefined ? {} : { runtime: status(telemetry) })
406
406
  }
407
407
  });
@@ -675,7 +675,7 @@ export function buildRuntimeIdentityReceipt(input) {
675
675
  }
676
676
  })();
677
677
  return Object.freeze({
678
- schemaVersion: 1,
678
+ schemaVersion: 2,
679
679
  version: YUI_VERSION,
680
680
  executablePath: process.execPath,
681
681
  args: process.argv.slice(1),
@@ -685,8 +685,8 @@ export function buildRuntimeIdentityReceipt(input) {
685
685
  cliRealpath: controllerCliRealpath(),
686
686
  controllerRealpath: realpathSync(fileURLToPath(import.meta.url)),
687
687
  controllerProtocolVersion: FILE_TASK_CONTROLLER_PROTOCOL_VERSION,
688
- storageLayoutVersion: yuiVersionIdentity().storageLayoutVersion,
689
- aggregateSchemaVersion: yuiVersionIdentity().aggregateSchemaVersion,
688
+ storageVersion: yuiVersionIdentity().storageVersion,
689
+ minimumStorageVersion: yuiVersionIdentity().minimumStorageVersion,
690
690
  storageBackend: input.storageBackend,
691
691
  workerEnabled: input.workerEnabled,
692
692
  pid: process.pid,
@@ -34,7 +34,7 @@ export const STORAGE_DOCTOR_CHECK_NAMES = Object.freeze([
34
34
  * healthy only when schema, compatibility, and state are all `ok`. Any
35
35
  * `unsupported` (version mismatch / needs-new-version), `invalid` (corrupted /
36
36
  * unreadable), or `missing` (uninitialized) storage check is blocking — even
37
- * though `yui doctor` itself exits 0.
37
+ * and the machine-readable command exits non-zero.
38
38
  */
39
39
  export function summarizeStorageHealth(checks) {
40
40
  const names = new Set(STORAGE_DOCTOR_CHECK_NAMES);
@@ -56,14 +56,16 @@ export function buildDoctorReport(env, executor) {
56
56
  }
57
57
  /**
58
58
  * Read the physical-backend facts for a Home (Issue 01 observability). Every
59
- * field is best-effort read-only evidence: an unreadable database or manifest
60
- * yields `null` for that field, and the `storage state` check reports the
61
- * structural problem separately.
59
+ * field is best-effort read-only evidence: an unreadable database yields
60
+ * `null` for that field, and the `storage state` check reports the structural
61
+ * problem separately.
62
62
  */
63
63
  function inspectStorageDetails(home, env) {
64
64
  const schema = inspectStorageSchema(home);
65
- const logicalLayout = schema.status === "current" || schema.status === "unsupported"
66
- ? schema.currentLayoutVersion
65
+ const storageVersion = schema.status === "current"
66
+ || schema.status === "upgradeable"
67
+ || schema.status === "unsupported"
68
+ ? schema.currentVersion
67
69
  : null;
68
70
  const authoritativeBackend = "sqlite";
69
71
  const dbPath = join(home, CURRENT_DATABASE_FILENAME);
@@ -88,7 +90,8 @@ function inspectStorageDetails(home, env) {
88
90
  }
89
91
  }
90
92
  return {
91
- logicalLayout,
93
+ storageVersion,
94
+ minimumSupportedVersion: schema.minimumSupportedVersion,
92
95
  authoritativeBackend,
93
96
  databasePath,
94
97
  journalMode,
@@ -216,13 +219,22 @@ function checkSchema(state) {
216
219
  return {
217
220
  name: "storage schema",
218
221
  status: "ok",
219
- detail: `current=${state.currentVersion} latest=${state.latestVersion}`
222
+ detail: `current=${state.currentVersion} latest=${state.latestVersion} `
223
+ + `minimum=${state.minimumSupportedVersion}`
224
+ };
225
+ case "upgradeable":
226
+ return {
227
+ name: "storage schema",
228
+ status: "unsupported",
229
+ detail: `current=${state.currentVersion} latest=${state.latestVersion} `
230
+ + `minimum=${state.minimumSupportedVersion} migration=available`
220
231
  };
221
232
  case "unsupported":
222
233
  return {
223
234
  name: "storage schema",
224
235
  status: "unsupported",
225
- detail: `current=${state.currentVersion} latest=${state.latestVersion} direction=${state.direction}`
236
+ detail: `current=${state.currentVersion} latest=${state.latestVersion} `
237
+ + `minimum=${state.minimumSupportedVersion} direction=${state.direction}`
226
238
  };
227
239
  case "invalid":
228
240
  return { name: "storage schema", status: "invalid", detail: state.detail };
@@ -251,9 +263,20 @@ function inspectCompatibility(home, homeCheck, schema) {
251
263
  check: {
252
264
  name,
253
265
  status: "unsupported",
254
- detail: `unsupported contract: ${schema.incompatibleComponent} is ${schema.direction} `
255
- + `(current=${schema.currentVersion}, required=${schema.latestVersion}). `
256
- + "Preserve this Home and initialize a new Home."
266
+ detail: `storage version is ${schema.direction} `
267
+ + `(current=${schema.currentVersion}, supported=`
268
+ + `${schema.minimumSupportedVersion}..${schema.latestVersion}).`
269
+ }
270
+ };
271
+ }
272
+ if (schema.status === "upgradeable") {
273
+ return {
274
+ storageStatus: "upgradeable",
275
+ check: {
276
+ name,
277
+ status: "unsupported",
278
+ detail: `migration available from ${schema.currentVersion} to ${schema.latestVersion}; `
279
+ + "run `yui upgrade` or `yui update`."
257
280
  }
258
281
  };
259
282
  }
@@ -262,8 +285,8 @@ function inspectCompatibility(home, homeCheck, schema) {
262
285
  check: {
263
286
  name,
264
287
  status: "ok",
265
- detail: `current layout=${schema.currentLayoutVersion}/${schema.latestLayoutVersion} `
266
- + `aggregate=${schema.currentAggregateSchemaVersion}/${schema.latestAggregateSchemaVersion}`
288
+ detail: `current=${schema.currentVersion}/${schema.latestVersion} `
289
+ + `minimum=${schema.minimumSupportedVersion}`
267
290
  }
268
291
  };
269
292
  }
@@ -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
  ? {}
@@ -13,7 +13,8 @@ import { spawnSync } from "node:child_process";
13
13
  import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
14
14
  import { join } from "node:path";
15
15
  import Database from "better-sqlite3";
16
- import { CURRENT_AGGREGATE_SCHEMA_VERSION, CURRENT_STORAGE_LAYOUT_VERSION } from "../storage/storageVersions.js";
16
+ import { CURRENT_STORAGE_VERSION, MIN_SUPPORTED_STORAGE_VERSION } from "../storage/storageVersions.js";
17
+ import { inspectStorageSchema } from "../storage/storageSchema.js";
17
18
  import { resolveStoreWorkerEnabledForHome } from "../storage/storeRpc.js";
18
19
  export const UNSUPPORTED = "unsupported";
19
20
  /**
@@ -140,14 +141,7 @@ export function collectRuntimeBuildIdentity(ports) {
140
141
  export function createProductionStorageIdentityPorts(env = process.env) {
141
142
  return {
142
143
  env,
143
- readText: (path) => {
144
- try {
145
- return readFileSync(path, "utf8");
146
- }
147
- catch {
148
- return null;
149
- }
150
- },
144
+ inspectStorage: (home) => inspectStorageSchema(home),
151
145
  fileSize: (path) => {
152
146
  try {
153
147
  return statSync(path).size;
@@ -181,36 +175,12 @@ export function createProductionStorageIdentityPorts(env = process.env) {
181
175
  * are never treated as an alternate authority or a repair source.
182
176
  */
183
177
  export function collectStorageIdentity(home, ports = createProductionStorageIdentityPorts()) {
184
- const manifestPath = join(home, "schema.json");
185
- const manifestText = ports.readText(manifestPath);
186
- let manifestStatus = "uninitialized";
187
- let logicalLayout = UNSUPPORTED;
188
- let aggregateSchemaVersion = UNSUPPORTED;
189
- if (manifestText !== null) {
190
- manifestStatus = "current";
191
- try {
192
- const manifest = JSON.parse(manifestText);
193
- if (typeof manifest.storageVersion === "number"
194
- && Number.isFinite(manifest.storageVersion)) {
195
- logicalLayout = manifest.storageVersion;
196
- }
197
- if (typeof manifest.aggregateSchemaVersion === "number"
198
- && Number.isFinite(manifest.aggregateSchemaVersion)) {
199
- aggregateSchemaVersion = manifest.aggregateSchemaVersion;
200
- }
201
- if (typeof logicalLayout !== "number"
202
- || typeof aggregateSchemaVersion !== "number"
203
- || logicalLayout !== CURRENT_STORAGE_LAYOUT_VERSION
204
- || aggregateSchemaVersion !== CURRENT_AGGREGATE_SCHEMA_VERSION) {
205
- manifestStatus = "unsupported";
206
- }
207
- }
208
- catch {
209
- manifestStatus = "invalid";
210
- logicalLayout = UNSUPPORTED;
211
- aggregateSchemaVersion = UNSUPPORTED;
212
- }
213
- }
178
+ const storage = ports.inspectStorage(home);
179
+ const storageVersion = storage.status === "current"
180
+ || storage.status === "upgradeable"
181
+ || storage.status === "unsupported"
182
+ ? storage.currentVersion
183
+ : UNSUPPORTED;
214
184
  const statePath = join(home, "state.json");
215
185
  const statePresent = ports.exists(statePath);
216
186
  const stateBytes = ports.fileSize(statePath);
@@ -223,7 +193,7 @@ export function collectStorageIdentity(home, ports = createProductionStorageIden
223
193
  const workerEnabled = resolveStoreWorkerEnabledForHome(home, ports.env);
224
194
  const dbHealth = dbPresent ? ports.probeDatabaseHealth(dbPath) : null;
225
195
  const findings = [];
226
- if (manifestStatus === "current" && !dbPresent) {
196
+ if (storage.status === "current" && !dbPresent) {
227
197
  findings.push({
228
198
  code: "current-database-missing",
229
199
  severity: "contradiction",
@@ -231,20 +201,48 @@ export function collectStorageIdentity(home, ports = createProductionStorageIden
231
201
  remediation: "Preserve this Home for diagnosis and initialize a new Home."
232
202
  });
233
203
  }
234
- if (manifestStatus === "unsupported") {
204
+ if (storage.status === "uninitialized") {
205
+ findings.push({
206
+ code: "storage-uninitialized",
207
+ severity: "needs-repair",
208
+ message: "Yui storage has not been initialized for this Home.",
209
+ remediation: "Run `yui setup` for a new, empty Home."
210
+ });
211
+ }
212
+ if (storage.status === "upgradeable") {
213
+ findings.push({
214
+ code: "storage-upgrade-required",
215
+ severity: "needs-repair",
216
+ message: `Storage version ${storage.currentVersion} requires migration to `
217
+ + `${CURRENT_STORAGE_VERSION}.`,
218
+ remediation: "Run `yui upgrade` or `yui update` before resuming writes."
219
+ });
220
+ }
221
+ if (storage.status === "unsupported") {
235
222
  findings.push({
236
223
  code: "unsupported-storage-contract",
237
224
  severity: "contradiction",
238
- message: "The Home does not match this release's exact storage contract.",
239
- remediation: "Open it read-only with its original Yui version, then let the Operator recreate unfinished work in a new Home."
225
+ message: `Storage version ${storage.currentVersion} is outside the supported migration range `
226
+ + `${MIN_SUPPORTED_STORAGE_VERSION}..${CURRENT_STORAGE_VERSION}.`,
227
+ remediation: storage.direction === "newer"
228
+ ? "Use a newer Yui release."
229
+ : "Use a Yui release whose migration floor includes this Home."
240
230
  });
241
231
  }
242
- if (manifestStatus === "invalid") {
232
+ if (storage.status === "invalid") {
243
233
  findings.push({
244
- code: "invalid-storage-manifest",
234
+ code: "invalid-storage",
245
235
  severity: "contradiction",
246
- message: "schema.json is invalid.",
247
- remediation: "Preserve this Home for diagnosis and initialize a new Home."
236
+ message: storage.detail,
237
+ remediation: "Preserve this Home for diagnosis and restore a known-good backup."
238
+ });
239
+ }
240
+ if (ports.exists(join(home, "schema.json"))) {
241
+ findings.push({
242
+ code: "ignored-legacy-storage-manifest",
243
+ severity: "warning",
244
+ message: "schema.json is legacy metadata and is not a storage authority.",
245
+ remediation: "A successful storage upgrade removes it automatically."
248
246
  });
249
247
  }
250
248
  if (dbPresent && dbHealth !== null && dbHealth !== "ok") {
@@ -274,9 +272,9 @@ export function collectStorageIdentity(home, ports = createProductionStorageIden
274
272
  }
275
273
  return {
276
274
  home,
277
- manifestStatus,
278
- logicalLayout,
279
- aggregateSchemaVersion,
275
+ storageStatus: storage.status,
276
+ storageVersion,
277
+ minimumStorageVersion: MIN_SUPPORTED_STORAGE_VERSION,
280
278
  configuredBackend,
281
279
  workerEnabled,
282
280
  physicalStateJson: {
@@ -484,7 +484,7 @@ function validatePointer(value) {
484
484
  function validateRuntimeIdentity(value) {
485
485
  if (value === null
486
486
  || typeof value !== "object"
487
- || value.schemaVersion !== 1
487
+ || value.schemaVersion !== 2
488
488
  || typeof value.version !== "string"
489
489
  || typeof value.executablePath !== "string"
490
490
  || value.executablePath === ""
@@ -492,6 +492,11 @@ function validateRuntimeIdentity(value) {
492
492
  || typeof value.buildId !== "string"
493
493
  || typeof value.cliRealpath !== "string"
494
494
  || typeof value.controllerRealpath !== "string"
495
+ || !isPositiveInteger(value.controllerProtocolVersion)
496
+ || !isPositiveInteger(value.storageVersion)
497
+ || !isPositiveInteger(value.minimumStorageVersion)
498
+ || value.minimumStorageVersion
499
+ > value.storageVersion
495
500
  || typeof value.pid !== "number"
496
501
  || typeof value.processStartIdentity !== "string"
497
502
  || (value.mode !== "primary"
@@ -520,6 +525,9 @@ function isHandoverPhase(value) {
520
525
  function isStringArray(value) {
521
526
  return Array.isArray(value) && value.every((entry) => typeof entry === "string");
522
527
  }
528
+ function isPositiveInteger(value) {
529
+ return Number.isSafeInteger(value) && value > 0;
530
+ }
523
531
  function isEexist(error) {
524
532
  return isNodeError(error) && error.code === "EEXIST";
525
533
  }
@@ -17,6 +17,7 @@ import { persistRuntimeProcessExitObservation, replayRuntimeProcessExitOutbox }
17
17
  import { readRuntimeStopReceipt, removeRuntimeStopReceipt } from "./runtimeStopReceipt.js";
18
18
  import { AGENT_HOST_CONTROL_TIMEOUT_MS, AGENT_HOST_READY_TIMEOUT_MS } from "./runtimeDeadlines.js";
19
19
  import { serializeAgentErrorRaw } from "./agentError.js";
20
+ import { runCodexInteractiveHost } from "./codexInteractiveHost.js";
20
21
  export const AGENT_HOST_CONTROL_PROTOCOL = "yui-agent-host/v4";
21
22
  const HOST_CONTROL_MAX_BYTES = 32 * 1024;
22
23
  const CODEX_CLIENT_STABLE_MS = 5_000;
@@ -28,6 +29,11 @@ export async function runAgentHost(input) {
28
29
  const hostInstanceId = randomUUID();
29
30
  let hostSequence = 0;
30
31
  let payload = await redeem(input.home, input.runtimeGenerationId, input.ticket);
32
+ if (payload.environment.YUI_SESSION_SCOPE === "global"
33
+ && payload.environment.YUI_ADAPTER_ID === "codex"
34
+ && payload.providerControl === undefined) {
35
+ return runCodexInteractiveHost(input.home, payload);
36
+ }
31
37
  let session;
32
38
  let sessionPayload;
33
39
  let activeTurnPayload;
@@ -882,6 +888,7 @@ export async function waitForAgentHostLaunchAck(input) {
882
888
  const code = error.code;
883
889
  if (code !== "ENOENT" && code !== "ECONNREFUSED")
884
890
  throw error;
891
+ await input.assertHostRunning?.();
885
892
  }
886
893
  await new Promise((resolvePromise) => setTimeout(resolvePromise, 50));
887
894
  }