@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
@@ -0,0 +1,191 @@
1
+ import { spawn } from "node:child_process";
2
+ import { randomBytes } from "node:crypto";
3
+ import { once } from "node:events";
4
+ import WebSocket, { WebSocketServer } from "ws";
5
+ import { AGENT_HOST_CONTROL_PROTOCOL, openAgentHostControl } from "./agentHost.js";
6
+ import { openCodexInteractiveConnection } from "./structuredProviderHost.js";
7
+ /**
8
+ * Keep the native TUI and its transparent App Server attachment in one pane.
9
+ * The existing Host acknowledgement carries the ID from that TUI's exact
10
+ * thread/start or thread/resume response, before any user/model Turn.
11
+ *
12
+ * Codex 0.150.1 cannot resume a pre-created empty Thread: no rollout exists
13
+ * until its first message. Observing the TUI's own startup avoids creating a
14
+ * second Thread or manufacturing a bootstrap message to materialize history.
15
+ */
16
+ export async function runCodexInteractiveHost(home, payload) {
17
+ const environment = payload.environment;
18
+ if (environment.YUI_SESSION_SCOPE !== "global" || environment.YUI_ADAPTER_ID !== "codex"
19
+ || payload.providerControl !== undefined) {
20
+ throw new Error("Interactive Codex Host requires a global native TUI launch.");
21
+ }
22
+ const remoteIndex = payload.args.indexOf("--remote");
23
+ if (remoteIndex < 0 || payload.args[remoteIndex + 1] !== "unix://") {
24
+ throw new Error("Global Codex must target the default shared daemon.");
25
+ }
26
+ const baseArgs = JSON.parse(environment.YUI_AGENT_BASE_ARGS ?? "[]");
27
+ if (!Array.isArray(baseArgs) || baseArgs.some((arg) => typeof arg !== "string")) {
28
+ throw new Error("Codex proxy base arguments are invalid.");
29
+ }
30
+ const expectedId = environment.YUI_NATIVE_SESSION_ID;
31
+ let snapshot = {
32
+ schemaVersion: 2, state: "starting", adapterId: "codex",
33
+ runtimeGenerationId: payload.runtimeGenerationId, updatedAt: new Date().toISOString()
34
+ };
35
+ const control = await openAgentHostControl(home, payload, () => snapshot, async (request) => {
36
+ if (request.type !== "status") {
37
+ throw new Error("Global Codex uses its native TUI; stop it before switching Sessions.");
38
+ }
39
+ return { protocol: AGENT_HOST_CONTROL_PROTOCOL, outcome: "status", snapshot };
40
+ });
41
+ let child;
42
+ let client;
43
+ let closing = false;
44
+ let startupRequestId;
45
+ let connection;
46
+ let relay;
47
+ const signals = ["SIGTERM", "SIGHUP", "SIGINT"];
48
+ const stop = () => { child?.kill("SIGTERM"); };
49
+ const fail = (error) => {
50
+ if (closing || snapshot.state === "failed")
51
+ return;
52
+ snapshot = {
53
+ ...snapshot, state: "failed",
54
+ detail: error instanceof Error ? error.message : String(error),
55
+ updatedAt: new Date().toISOString()
56
+ };
57
+ process.stderr.write(`Yui Codex attachment failed: ${snapshot.detail}\n`);
58
+ client?.terminate();
59
+ stop();
60
+ };
61
+ try {
62
+ // This proxy is a disposable client, not the daemon. No Yui initialization
63
+ // or Thread request is injected; all requests below belong to the TUI.
64
+ connection = await openCodexInteractiveConnection({
65
+ command: payload.command, args: [...baseArgs, "app-server", "proxy"],
66
+ environment, cwd: payload.cwd
67
+ });
68
+ connection.onClose(fail);
69
+ const token = randomBytes(32).toString("hex");
70
+ relay = new WebSocketServer({
71
+ host: "127.0.0.1", port: 0,
72
+ maxPayload: 16 * 1024 * 1024, perMessageDeflate: false,
73
+ verifyClient: (info) => info.req.headers.authorization === `Bearer ${token}`
74
+ });
75
+ relay.on("error", fail);
76
+ relay.on("connection", (socket) => {
77
+ if (client !== undefined || closing) {
78
+ socket.close(1008, "This attachment already has its native TUI.");
79
+ return;
80
+ }
81
+ client = socket;
82
+ socket.on("error", fail);
83
+ socket.on("close", () => {
84
+ if (!closing) {
85
+ closing = true;
86
+ connection?.close();
87
+ stop();
88
+ }
89
+ });
90
+ socket.on("message", (data, binary) => {
91
+ try {
92
+ if (binary)
93
+ throw new Error("Codex TUI sent a binary App Server request.");
94
+ const message = jsonObject(JSON.parse(data.toString()));
95
+ if (snapshot.nativeSessionId === undefined
96
+ && (message.method === "thread/start" || message.method === "thread/resume")) {
97
+ if (startupRequestId !== undefined)
98
+ throw new Error("Codex sent overlapping startup requests.");
99
+ if (typeof message.id !== "string" && typeof message.id !== "number") {
100
+ throw new Error("Codex startup request has no correlation id.");
101
+ }
102
+ const params = jsonObject(message.params);
103
+ if (expectedId === undefined ? message.method !== "thread/start"
104
+ : message.method !== "thread/resume" || params.threadId !== expectedId) {
105
+ throw new Error("Codex TUI startup does not match the reserved Session.");
106
+ }
107
+ startupRequestId = message.id;
108
+ }
109
+ void connection.send(message).catch(fail);
110
+ }
111
+ catch (error) {
112
+ fail(error);
113
+ }
114
+ });
115
+ });
116
+ connection.onMessage((message) => {
117
+ try {
118
+ let nativeSessionId;
119
+ if (startupRequestId !== undefined && message.id === startupRequestId
120
+ && message.method === undefined) {
121
+ if (message.error !== undefined) {
122
+ throw new Error(`Codex startup failed: ${JSON.stringify(message.error)}`);
123
+ }
124
+ const id = jsonObject(jsonObject(message.result).thread).id;
125
+ if (typeof id !== "string" || id.length === 0 || id.trim() !== id || id.includes("\0")
126
+ || (expectedId !== undefined && id !== expectedId)) {
127
+ throw new Error("Codex startup returned an invalid or mismatched Thread identity.");
128
+ }
129
+ nativeSessionId = id;
130
+ }
131
+ if (client?.readyState !== WebSocket.OPEN)
132
+ throw new Error("Codex TUI attachment is closed.");
133
+ client.send(JSON.stringify(message), (error) => {
134
+ if (error) {
135
+ fail(error);
136
+ return;
137
+ }
138
+ if (nativeSessionId === undefined || snapshot.state === "failed")
139
+ return;
140
+ startupRequestId = undefined;
141
+ snapshot = {
142
+ ...snapshot, state: "ready", nativeSessionId, conversationId: nativeSessionId,
143
+ updatedAt: new Date().toISOString()
144
+ };
145
+ });
146
+ }
147
+ catch (error) {
148
+ fail(error);
149
+ }
150
+ });
151
+ await once(relay, "listening");
152
+ if (snapshot.state === "failed")
153
+ throw new Error(snapshot.detail);
154
+ const address = relay.address();
155
+ if (typeof address !== "object" || address === null)
156
+ throw new Error("Codex TUI relay did not bind.");
157
+ const args = [...payload.args];
158
+ args[remoteIndex + 1] = `ws://127.0.0.1:${address.port}`;
159
+ args.splice(remoteIndex + 2, 0, "--remote-auth-token-env", "YUI_CODEX_REMOTE_AUTH_TOKEN");
160
+ child = spawn(payload.command, args, {
161
+ cwd: payload.cwd,
162
+ env: { ...environment, YUI_CODEX_REMOTE_AUTH_TOKEN: token },
163
+ stdio: "inherit"
164
+ });
165
+ for (const signal of signals)
166
+ process.on(signal, stop);
167
+ const [code] = await once(child, "exit");
168
+ return typeof code === "number" ? code : 1;
169
+ }
170
+ catch (error) {
171
+ fail(error);
172
+ throw error;
173
+ }
174
+ finally {
175
+ closing = true;
176
+ for (const signal of signals)
177
+ process.removeListener(signal, stop);
178
+ stop();
179
+ client?.terminate();
180
+ connection?.close();
181
+ if (relay !== undefined)
182
+ await new Promise((done) => relay.close(() => done()));
183
+ await control.close();
184
+ }
185
+ }
186
+ function jsonObject(value) {
187
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
188
+ throw new Error("Invalid Codex App Server object.");
189
+ }
190
+ return value;
191
+ }
@@ -73,7 +73,7 @@ export function extractExactControlArgument(args) {
73
73
  }
74
74
  /**
75
75
  * One read-only gate shared by every managed Task control command. It verifies
76
- * the frozen executable/CLI/Home/digest, protocol and scalar-storage identity,
76
+ * the frozen executable/CLI/Home/digest, protocol and storage identity,
77
77
  * on-disk schema, and any live Controller before command routing may construct
78
78
  * a writable store. Package version alone may advance at the same managed path
79
79
  * so an existing Session can cross an explicitly compatible in-place update.
@@ -92,15 +92,10 @@ export async function assertExactControlPlanePreflight(input, options = {}) {
92
92
  if (storage.status !== "current") {
93
93
  throw new Error(`Exact control-plane storage is not current: ${storage.status}.`);
94
94
  }
95
- if (storage.currentLayoutVersion !== descriptor.identity.storageLayoutVersion) {
96
- throw new Error("Exact control-plane storage layout does not match its frozen descriptor "
97
- + `(expected ${descriptor.identity.storageLayoutVersion}, found `
98
- + `${storage.currentLayoutVersion ?? "unknown"}).`);
99
- }
100
- if (storage.currentAggregateSchemaVersion !== descriptor.identity.aggregateSchemaVersion) {
101
- throw new Error("Exact control-plane aggregate schema does not match its frozen descriptor "
102
- + `(expected ${descriptor.identity.aggregateSchemaVersion}, found `
103
- + `${storage.currentAggregateSchemaVersion ?? "unknown"}).`);
95
+ if (storage.currentVersion !== descriptor.identity.storageVersion) {
96
+ throw new Error("Exact control-plane storage version does not match its frozen descriptor "
97
+ + `(expected ${descriptor.identity.storageVersion}, found `
98
+ + `${storage.currentVersion ?? "unknown"}).`);
104
99
  }
105
100
  // A frozen descriptor authenticates the command that created it; it no
106
101
  // longer pins the Home's deployment pointer for the lifetime of a Session.
@@ -133,15 +128,10 @@ export async function assertCompatibleControlPlanePreflight(input, options = {})
133
128
  if (storage.status !== "current") {
134
129
  throw new Error(`Managed control-plane storage is not current: ${storage.status}.`);
135
130
  }
136
- if (storage.currentLayoutVersion !== identity.storageLayoutVersion) {
137
- throw new Error("Managed control-plane storage layout is incompatible "
138
- + `(expected ${identity.storageLayoutVersion}, found `
139
- + `${storage.currentLayoutVersion ?? "unknown"}).`);
140
- }
141
- if (storage.currentAggregateSchemaVersion !== identity.aggregateSchemaVersion) {
142
- throw new Error("Managed control-plane aggregate schema is incompatible "
143
- + `(expected ${identity.aggregateSchemaVersion}, found `
144
- + `${storage.currentAggregateSchemaVersion ?? "unknown"}).`);
131
+ if (storage.currentVersion !== identity.storageVersion) {
132
+ throw new Error("Managed control-plane storage version is incompatible "
133
+ + `(expected ${identity.storageVersion}, found `
134
+ + `${storage.currentVersion ?? "unknown"}).`);
145
135
  }
146
136
  if (options.checkController !== false) {
147
137
  const call = options.callController ?? defaultCallController;
@@ -162,29 +152,31 @@ export function assertControllerStatusIdentity(status, expected = yuiVersionIden
162
152
  }
163
153
  assertControllerField(status.protocolVersion, expected.controllerProtocolVersion, "protocol");
164
154
  assertControllerField(status.version, expected.version, "version");
165
- assertControllerField(status.storageLayoutVersion, expected.storageLayoutVersion, "storage layout");
166
- assertControllerField(status.aggregateSchemaVersion, expected.aggregateSchemaVersion, "aggregate schema");
155
+ assertControllerField(status.storageVersion, expected.storageVersion, "storage version");
156
+ assertControllerField(status.minimumStorageVersion, expected.minimumStorageVersion, "minimum storage migration version");
167
157
  }
168
158
  function validateVersionIdentity(value) {
169
159
  if (!isRecord(value))
170
160
  throw new Error("Yui version identity is invalid.");
171
161
  const version = requireText(value.version, "Yui version");
172
162
  const controllerProtocolVersion = requireVersion(value.controllerProtocolVersion, "Controller protocol version");
173
- const storageLayoutVersion = requireVersion(value.storageLayoutVersion, "Storage layout version");
174
- const aggregateSchemaVersion = requireVersion(value.aggregateSchemaVersion, "Aggregate schema version");
163
+ const storageVersion = requireVersion(value.storageVersion, "Storage version");
164
+ const minimumStorageVersion = requireVersion(value.minimumStorageVersion, "Minimum storage migration version");
165
+ if (minimumStorageVersion > storageVersion) {
166
+ throw new Error("Minimum storage migration version cannot exceed the current storage version.");
167
+ }
175
168
  return {
176
169
  version,
177
170
  controllerProtocolVersion,
178
- storageLayoutVersion,
179
- aggregateSchemaVersion
171
+ storageVersion,
172
+ minimumStorageVersion
180
173
  };
181
174
  }
182
175
  /** Managed continuity is a protocol/storage contract, not a package pin. */
183
176
  function assertContinuityIdentity(label, expected, actual) {
184
177
  for (const field of [
185
178
  "controllerProtocolVersion",
186
- "storageLayoutVersion",
187
- "aggregateSchemaVersion"
179
+ "storageVersion"
188
180
  ]) {
189
181
  if (expected[field] !== actual[field]) {
190
182
  throw new Error(`${label} ${field} does not match the frozen control plane `
@@ -200,8 +192,7 @@ function assertControllerContinuityIdentity(status, expected) {
200
192
  throw new Error("Controller version is invalid at the managed continuity gate.");
201
193
  }
202
194
  assertControllerField(status.protocolVersion, expected.controllerProtocolVersion, "protocol");
203
- assertControllerField(status.storageLayoutVersion, expected.storageLayoutVersion, "storage layout");
204
- assertControllerField(status.aggregateSchemaVersion, expected.aggregateSchemaVersion, "aggregate schema");
195
+ assertControllerField(status.storageVersion, expected.storageVersion, "storage version");
205
196
  }
206
197
  function assertControllerField(actual, expected, label) {
207
198
  if (actual !== expected) {
@@ -78,11 +78,32 @@ export async function startStructuredProviderSession(payload, input = {}) {
78
78
  throw error;
79
79
  }
80
80
  }
81
+ /**
82
+ * Open an uninitialized, transparent connection for a native TUI. The TUI
83
+ * owns its requests; Yui observes only its exact startup response.
84
+ */
85
+ export async function openCodexInteractiveConnection(launch) {
86
+ const child = spawn(launch.command, [...launch.args], {
87
+ cwd: launch.cwd,
88
+ env: { ...launch.environment },
89
+ stdio: ["pipe", "pipe", "pipe"],
90
+ detached: true
91
+ });
92
+ child.stderr.resume();
93
+ try {
94
+ return await CodexProxyWebSocketChannel.connect(child, () => { });
95
+ }
96
+ catch (error) {
97
+ terminateProcessGroup(child, "SIGTERM");
98
+ throw error;
99
+ }
100
+ }
81
101
  class CodexProxyWebSocketChannel {
82
102
  child;
83
103
  mirror;
84
104
  #pending = new Map();
85
105
  #listeners = new Set();
106
+ #closeListeners = new Set();
86
107
  #nextId = 1;
87
108
  #closedError;
88
109
  #ready = false;
@@ -127,6 +148,17 @@ class CodexProxyWebSocketChannel {
127
148
  this.#listeners.add(listener);
128
149
  return () => this.#listeners.delete(listener);
129
150
  }
151
+ onClose(listener) {
152
+ if (this.#closedError !== undefined)
153
+ listener(this.#closedError);
154
+ else
155
+ this.#closeListeners.add(listener);
156
+ return () => this.#closeListeners.delete(listener);
157
+ }
158
+ close() {
159
+ this.#webSocket.terminate();
160
+ this.#close(new Error("Codex App Server proxy client closed."));
161
+ }
130
162
  async request(method, params) {
131
163
  await this.#readyPromise;
132
164
  if (this.#closedError !== undefined)
@@ -227,6 +259,9 @@ class CodexProxyWebSocketChannel {
227
259
  pending.reject(error);
228
260
  }
229
261
  this.#pending.clear();
262
+ for (const listener of this.#closeListeners)
263
+ listener(error);
264
+ this.#closeListeners.clear();
230
265
  if (this.child.exitCode === null && this.child.signalCode === null) {
231
266
  terminateProcessGroup(this.child, "SIGTERM");
232
267
  }
@@ -210,6 +210,23 @@ export class TmuxSessionHost {
210
210
  throw toRuntimeLaunchFailure(error, "validation", launchContext);
211
211
  }
212
212
  }
213
+ const interactiveCodex = request.owner.scope === "global" && request.adapterId === "codex";
214
+ let reuseInteractivePane = false;
215
+ if (interactiveCodex) {
216
+ let status;
217
+ try {
218
+ status = await probeRoleStatus(this.tmux, hostId, request.owner.roleName);
219
+ }
220
+ catch (error) {
221
+ throw new RuntimeHostContentionError("previous-process", `Global Role pane state is unknown; preserving it: ${error instanceof Error ? error.message : String(error)}`);
222
+ }
223
+ if (status === "running") {
224
+ if (request.mode === "new") {
225
+ throw new RuntimeHostContentionError("previous-process", "Global Role already has a live pane; stop or record its exact Session first.");
226
+ }
227
+ reuseInteractivePane = true;
228
+ }
229
+ }
213
230
  const plannedNativeSessionId = planned.session?.nativeSessionId;
214
231
  if (request.mode === "resume"
215
232
  && plannedNativeSessionId !== undefined
@@ -232,23 +249,26 @@ export class TmuxSessionHost {
232
249
  });
233
250
  const yuiHome = planned.launch.env.YUI_HOME;
234
251
  const childLifecycle = planned.launch.childLifecycle;
235
- // Interactive/global Roles remain native TUIs even when their Driver
236
- // advertises a persistent child lifecycle. Provider control metadata is
237
- // the discriminator for the structured Agent Host path. A managed Task
238
- // Turn has no terminal-write fallback and must expose that contract.
239
- if (yuiHome === undefined
240
- || childLifecycle === undefined
241
- || planned.launch.providerControl === undefined) {
252
+ // Global Codex retains the native TUI inside a thin Host that acknowledges
253
+ // its own App Server startup response. Existing live TUIs (including
254
+ // 0.15.0 Sessions) remain directly attachable without replacing them.
255
+ if (reuseInteractivePane
256
+ || (!interactiveCodex && (yuiHome === undefined
257
+ || childLifecycle === undefined
258
+ || planned.launch.providerControl === undefined))) {
242
259
  if (request.owner.scope === "task" && request.turnId !== undefined) {
243
260
  throw new Error("Managed Task Turn is missing its structured Agent Host contract.");
244
261
  }
245
262
  let hostCreated;
246
263
  try {
247
- hostCreated = await ensureRoleWindow(this.tmux, hostId, planned.role, planned.launch);
264
+ hostCreated = await ensureRoleWindow(this.tmux, hostId, planned.role, reuseInteractivePane ? undefined : planned.launch);
248
265
  }
249
266
  catch (error) {
250
267
  throw toRuntimeLaunchFailure(error, "host-start", launchContext);
251
268
  }
269
+ if (reuseInteractivePane && hostCreated) {
270
+ throw new Error("Global Role pane changed during attachment.");
271
+ }
252
272
  let binding = createRuntimeBinding({
253
273
  id: bindingId,
254
274
  runtimeGenerationId: request.runtimeGenerationId,
@@ -282,6 +302,9 @@ export class TmuxSessionHost {
282
302
  }
283
303
  return binding;
284
304
  }
305
+ if (yuiHome === undefined || childLifecycle === undefined) {
306
+ throw new Error("Agent Host launch is missing its Home or child lifecycle.");
307
+ }
285
308
  const broker = launchBrokerForHome(yuiHome);
286
309
  const sessionManifest = planned.launch.env.YUI_SESSION_MANIFEST;
287
310
  const frozenControlPlane = planned.launch.env[YUI_CONTROL_PLANE_DESCRIPTOR];
@@ -341,17 +364,36 @@ export class TmuxSessionHost {
341
364
  try {
342
365
  hostCreated = await ensureRoleWindow(this.tmux, hostId, planned.role, hostLaunch);
343
366
  if (hostCreated && planned.launch.deferProviderStart !== true) {
367
+ const assertInteractivePane = async () => {
368
+ const pane = await inspectRolePane(this.tmux, hostId, request.owner.roleName);
369
+ if (pane === undefined)
370
+ throw new Error("Global Codex startup has no observable pane state.");
371
+ if (pane.dead) {
372
+ await deadHostLaunchFailure(this.tmux, hostId, request.owner.roleName, pane, launchContext);
373
+ }
374
+ };
344
375
  providerSnapshot = await waitForAgentHostLaunchAck({
345
376
  home: yuiHome,
346
377
  scope: request.owner.scope,
347
378
  ...(request.owner.scope === "task" ? { taskId: request.owner.taskId } : {}),
348
379
  roleName: request.owner.roleName,
349
380
  runtimeGenerationId: reservation.runtimeGenerationId,
350
- requireTurnAck: false
381
+ requireTurnAck: false,
382
+ ...(interactiveCodex ? { assertHostRunning: assertInteractivePane } : {})
351
383
  });
384
+ if (interactiveCodex) {
385
+ if (providerSnapshot.nativeSessionId === undefined) {
386
+ throw new Error("Global Codex startup acknowledgement has no Thread identity.");
387
+ }
388
+ requireSafeIdentity(providerSnapshot.nativeSessionId, "Codex Thread identity");
389
+ await assertInteractivePane();
390
+ }
352
391
  providerDispatchObserved = true;
353
392
  }
354
393
  if (!hostCreated) {
394
+ if (interactiveCodex) {
395
+ throw new RuntimeHostContentionError("previous-process", "Global Role pane appeared during launch; preserving its existing Session.");
396
+ }
355
397
  const controlResult = await sendAgentHostLaunchControl({
356
398
  home: yuiHome,
357
399
  scope: request.owner.scope,
@@ -1,5 +1,5 @@
1
1
  import { serializeTurnInputEnvelope } from "../context/turnInputContract.js";
2
- import { effectiveLaunchSnapshotsCompatible, effectiveLaunchSnapshotsCompatibleForTaskSession } from "../executor/effectiveLaunch.js";
2
+ import { roleSessionMayContinue, sameEffectiveLaunch } from "../executor/effectiveLaunch.js";
3
3
  import { RuntimeLifecycleBusyError } from "../runtime/lifecycleReservation.js";
4
4
  import { managedProviderTurnId } from "../runtime/providerRuntimeIdentity.js";
5
5
  import { serializeAgentErrorRaw } from "../runtime/agentError.js";
@@ -231,8 +231,8 @@ function validateRoleSession(role, turn, existing, mode, session) {
231
231
  throw new Error(`Ready Role session identity changed: ${role.taskId}/${role.name}.`);
232
232
  }
233
233
  const compatible = mode === "resume"
234
- ? effectiveLaunchSnapshotsCompatibleForTaskSession(session.effective, turn.effective)
235
- : effectiveLaunchSnapshotsCompatible(session.effective, turn.effective);
234
+ ? roleSessionMayContinue(session.effective, turn.effective)
235
+ : sameEffectiveLaunch(session.effective, turn.effective);
236
236
  if (!compatible) {
237
237
  throw new Error(`Ready Role session effective snapshot changed: ${role.taskId}/${role.name}.`);
238
238
  }
@@ -249,7 +249,7 @@ function requireResumeSession(role, turn, session) {
249
249
  if (session === null || !hasText(session.nativeSessionId)) {
250
250
  throw new Error(`Role resume has no fixed native session: ${role.taskId}/${role.name}.`);
251
251
  }
252
- if (!effectiveLaunchSnapshotsCompatibleForTaskSession(session.effective, turn.effective)) {
252
+ if (!roleSessionMayContinue(session.effective, turn.effective)) {
253
253
  throw new Error(`Role resume effective snapshot drifted: ${role.taskId}/${role.name}.`);
254
254
  }
255
255
  return session.nativeSessionId;
@@ -1,5 +1,5 @@
1
1
  import { createTurnInput } from "../context/turnInputContract.js";
2
- import { effectiveLaunchSnapshotsCompatible, effectiveLaunchSnapshotsCompatibleForTaskSession } from "../executor/effectiveLaunch.js";
2
+ import { roleSessionMayContinue } from "../executor/effectiveLaunch.js";
3
3
  import { roleAgentSessionResumeMode } from "../executor/agentExecutor.js";
4
4
  import { createTurn } from "../turn/turn.js";
5
5
  import { isSchedulerTaskWorkspaceReady } from "./ports.js";
@@ -63,9 +63,8 @@ export async function processLeaderWakeups(store, delivery, now, selection) {
63
63
  try {
64
64
  const reopening = wakeup.reasons.includes("task-reopened");
65
65
  const existingSession = store.getRoleSession(task.id, role.name, reopening ? undefined : role.effective.agentId);
66
- const compatible = existingSession !== null && (reopening
67
- ? effectiveLaunchSnapshotsCompatible(existingSession.effective, role.effective)
68
- : effectiveLaunchSnapshotsCompatibleForTaskSession(existingSession.effective, role.effective));
66
+ const compatible = existingSession !== null
67
+ && roleSessionMayContinue(existingSession.effective, role.effective);
69
68
  if (hasNativeSession(existingSession)
70
69
  && existingSession.status === "active"
71
70
  && !compatible
@@ -2,12 +2,11 @@ import { existsSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { StorageRecordError } from "./taskStore.js";
4
4
  import { readSqliteHomeIdentity, SqliteTaskStore } from "./sqliteStore.js";
5
- import { ensureStorageSchema, inspectStorageSchema } from "./storageSchema.js";
6
- export const CURRENT_DATABASE_FILENAME = "yui.db";
5
+ import { CURRENT_DATABASE_FILENAME, inspectStorageSchema } from "./storageSchema.js";
6
+ export { CURRENT_DATABASE_FILENAME } from "./storageSchema.js";
7
7
  /** Initialize a new Home, or open an existing Home at the exact current contract. */
8
8
  export function initializeCurrentTaskStore(home) {
9
9
  if (inspectStorageSchema(home).status === "uninitialized") {
10
- ensureStorageSchema(home);
11
10
  return new SqliteTaskStore(home);
12
11
  }
13
12
  return openCurrentTaskStore(home);
@@ -16,7 +15,10 @@ export function initializeCurrentTaskStore(home) {
16
15
  export function openCurrentTaskStore(home) {
17
16
  const schema = inspectStorageSchema(home);
18
17
  if (schema.status !== "current") {
19
- throw new StorageRecordError("This Home does not use the current storage contract. Preserve it for read-only history and initialize a new Home.");
18
+ throw new StorageRecordError(schema.status === "upgradeable"
19
+ ? `This Home uses storage version ${schema.currentVersion}; run \`yui upgrade\` `
20
+ + `to reach ${schema.latestVersion}.`
21
+ : "This Home does not use a supported storage contract.");
20
22
  }
21
23
  if (!existsSync(join(home, CURRENT_DATABASE_FILENAME))) {
22
24
  throw new StorageRecordError("The current Home is incomplete: yui.db is missing. Preserve it for diagnosis and initialize a new Home.");