@zq-silk/yui 2.22.0 → 2.23.0

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 (47) hide show
  1. package/dist/cli/commandCatalog.js +2 -2
  2. package/dist/cli/updateCommand.js +20 -3
  3. package/dist/cli/updateOrchestrator.js +13 -0
  4. package/dist/cli/updatePorts.js +27 -0
  5. package/dist/commands/taskRoleRuntimeStatus.js +4 -0
  6. package/dist/context/currentSkills.js +32 -1
  7. package/dist/context/sessionBootstrapManifest.js +17 -4
  8. package/dist/context/sessionSkillNotice.js +33 -12
  9. package/dist/controller/clientRuntime.js +37 -18
  10. package/dist/controller/controller.js +16 -1
  11. package/dist/controller/fileSchedulerStoreAdapter.js +2 -0
  12. package/dist/controller/hostLifecycle.js +153 -0
  13. package/dist/controller/hostLifecycleChecks.js +27 -0
  14. package/dist/controller/hostUpgrade.js +344 -0
  15. package/dist/controller/runtime.js +67 -16
  16. package/dist/controller/sessionNotify.js +5 -46
  17. package/dist/execution/codexThreadNaming.js +54 -43
  18. package/dist/executor/fileRoleLaunchPlanner.js +18 -3
  19. package/dist/runtime/agentEndpoint.js +11 -1
  20. package/dist/runtime/agentEndpointIdentity.js +2 -0
  21. package/dist/runtime/agentHost.js +167 -42
  22. package/dist/runtime/agentHostImplementation.js +3 -1
  23. package/dist/runtime/codexAppServerRuntime.js +3 -0
  24. package/dist/runtime/codexInteractiveHost.js +62 -17
  25. package/dist/runtime/codexNativeAccess.js +1 -1
  26. package/dist/runtime/contextRecovery.js +15 -0
  27. package/dist/runtime/hostSessionLifecycle.js +83 -0
  28. package/dist/runtime/hostUpgradeGate.js +43 -0
  29. package/dist/runtime/lifecycleChecks.js +47 -0
  30. package/dist/runtime/lifecycleReservation.js +9 -1
  31. package/dist/runtime/nativeTitleReceipt.js +31 -0
  32. package/dist/runtime/nativeTitleSync.js +192 -0
  33. package/dist/runtime/providerRetry.js +4 -4
  34. package/dist/runtime/structuredProviderHost.js +42 -10
  35. package/dist/storage/sqliteSchema.js +20 -0
  36. package/dist/storage/storageVersions.js +1 -1
  37. package/docs/managed-turn-and-session-runtime.md +96 -1
  38. package/docs/managed-turn-and-session-runtime.zh-CN.md +70 -1
  39. package/docs/release-workflow.md +28 -0
  40. package/docs/release-workflow.zh-CN.md +20 -0
  41. package/docs/storage-baseline.md +10 -1
  42. package/docs/storage-baseline.zh-CN.md +5 -1
  43. package/docs/testing/verification-levels.md +2 -2
  44. package/docs/testing/verification-levels.zh-CN.md +2 -2
  45. package/package.json +1 -1
  46. package/skills/yui-runtime/SKILL.md +7 -2
  47. package/skills/yui-runtime/VERSION +1 -1
@@ -1289,8 +1289,8 @@ export const ROOT_COMMAND = buildNode({
1289
1289
  {
1290
1290
  name: "update", discovery: { surface: "public", audiences: ["public", "operator"] },
1291
1291
  summary: "Install the latest or an exact published Yui version after storage preflight.",
1292
- usage: "yui update [--version <exact-version>]",
1293
- options: ["--version"]
1292
+ usage: "yui update [--version <exact-version>] | --hosts-status",
1293
+ options: ["--version", "--hosts-status"]
1294
1294
  },
1295
1295
  {
1296
1296
  name: "upgrade", discovery: { surface: "public", audiences: ["public", "operator"] },
@@ -14,9 +14,16 @@ import { usageError } from "../errors/cliError.js";
14
14
  * Returns a process exit code: 0 on success or already-current, 5 on abort.
15
15
  */
16
16
  export function runUpdateCommand(args = [], environment = process.env, spawn, write = (text) => process.stdout.write(text), ports) {
17
- const version = parseUpdateVersion(args);
17
+ const statusOnly = args.length === 1 && args[0] === "--hosts-status";
18
+ const version = statusOnly ? undefined : parseUpdateVersion(args);
18
19
  const home = resolveYuiHome(environment);
19
20
  const resolvedPorts = ports ?? createUpdatePorts(environment, spawn);
21
+ if (statusOnly) {
22
+ if (!resolvedPorts.hostStatus)
23
+ throw usageError("Host status inspection is unavailable.");
24
+ write(`${renderHosts(resolvedPorts.hostStatus(home)).join("\n")}\n`);
25
+ return 0;
26
+ }
20
27
  const result = runUpdate(resolvedPorts, { home, version });
21
28
  write(`${renderUpdateResult(result)}\n`);
22
29
  if (result.outcome === "aborted")
@@ -29,7 +36,12 @@ function parseUpdateVersion(args) {
29
36
  if (args.length === 2 && args[0] === "--version" && isConcreteVersion(args[1])) {
30
37
  return args[1].trim();
31
38
  }
32
- throw usageError("Update usage: yui update [--version <exact-version>]");
39
+ throw usageError("Update usage: yui update [--version <exact-version>] | --hosts-status");
40
+ }
41
+ function renderHosts(hosts) {
42
+ return hosts.length === 0 ? ["Hosts: no recorded active Sessions."] : hosts.map(host => `Host ${host.owner.scope === "task" ? `${host.owner.taskId}/` : "global/"}${host.owner.roleName} `
43
+ + `(${host.nativeSessionId}): ${host.status}; loaded Host=${host.hostImplementation?.generation ?? "unreported"}; `
44
+ + `loaded Endpoint=${host.endpointImplementation?.generation ?? "unreported"}${host.detail ? ` — ${host.detail}` : ""}`);
33
45
  }
34
46
  /** Render an {@link UpdateResult} as concise, CLI-style text. */
35
47
  export function renderUpdateResult(result) {
@@ -66,7 +78,12 @@ export function renderUpdateResult(result) {
66
78
  return [
67
79
  rendered,
68
80
  ...(result.outcome === "updated" ? [
69
- "Runtime: existing Hosts are retained, not upgraded in place. Host control capabilities and event delivery are checked separately; accepted input is never replayed."
81
+ result.controllerStarted ? "Controller: target startup verified." : "Controller: not started by this update.",
82
+ "Hosts: graceful switching is separate from installation; accepted input is never replayed.",
83
+ ...(result.hosts === undefined ? ["Hosts: not confirmed; existing Hosts may still run older code."]
84
+ : renderHosts(result.hosts)),
85
+ "Inspect later without reinstalling: yui update --hosts-status",
86
+ ...(result.hostUpgradeError === undefined ? [] : [result.hostUpgradeError])
70
87
  ] : []),
71
88
  ...(result.controllerReconciliation === undefined ? [] : [
72
89
  `Controller reconciliation: ${JSON.stringify(result.controllerReconciliation)}`
@@ -250,9 +250,22 @@ function activateAndVerify(ports, staged, home, lifecycle, preflight) {
250
250
  : restoreControllerOrReport(ports, home, lifecycle, failure);
251
251
  }
252
252
  }
253
+ let hosts;
254
+ let hostUpgradeError;
255
+ if (lifecycle.ensureRunning && ports.upgradeHosts) {
256
+ try {
257
+ hosts = ports.upgradeHosts(home);
258
+ }
259
+ catch (error) {
260
+ hostUpgradeError = `Host upgrade request outcome is unknown: ${messageOf(error)}. Inspect existing requests; do not replay inputs.`;
261
+ }
262
+ }
253
263
  return {
254
264
  outcome: "updated",
255
265
  version: staged.version,
266
+ ...(lifecycle.ensureRunning ? { controllerStarted: true } : {}),
267
+ ...(hosts === undefined ? {} : { hosts }),
268
+ ...(hostUpgradeError === undefined ? {} : { hostUpgradeError }),
256
269
  ...(migration?.backupPath === undefined ? {} : { backupPath: migration.backupPath })
257
270
  };
258
271
  }
@@ -236,9 +236,36 @@ export function createUpdatePorts(environment, spawn = spawnSync, stagingRoot =
236
236
  },
237
237
  restoreController(home, identity) {
238
238
  restoreControllerIdentity(home, identity, environment, spawn);
239
+ },
240
+ upgradeHosts(home) {
241
+ if (!verifiedActivatedBinary)
242
+ throw new Error("Host upgrade requires the verified activated binary.");
243
+ const root = dirname(dirname(activatedControllerEntrypoint(verifiedActivatedBinary)));
244
+ return requestHosts(root, home, "runtime.upgrade-hosts", environment, spawn);
245
+ },
246
+ hostStatus(home) {
247
+ return requestHosts(fileURLToPath(new URL("../", import.meta.url)), home, "runtime.host-upgrade-status", environment, spawn);
239
248
  }
240
249
  };
241
250
  }
251
+ function requestHosts(root, home, method, environment, spawn) {
252
+ const helper = [
253
+ "const [root, home] = process.argv.slice(1);",
254
+ "(async () => {",
255
+ " const { pathToFileURL } = await import('node:url');",
256
+ " const { callController } = await import(pathToFileURL(root + '/core/controllerClient.js').href);",
257
+ " const { AGENT_HOST_IMPLEMENTATION } = await import(pathToFileURL(root + '/runtime/agentHostImplementation.js').href);",
258
+ ` const data = await callController(home, ${JSON.stringify(method)}, { generation: AGENT_HOST_IMPLEMENTATION.generation });`,
259
+ " process.stdout.write(JSON.stringify({ok: true, data}));",
260
+ "})().catch(error => { process.stderr.write(String(error)); process.exitCode = 5; });"
261
+ ].join(" ");
262
+ const result = spawn(process.execPath, ["-e", helper, root, home], { cwd: process.cwd(), env: { ...environment, YUI_HOME: home }, shell: false });
263
+ const data = parseSuccessEnvelopeData(result);
264
+ if (result.error || result.status !== 0 || !Array.isArray(data?.hosts)) {
265
+ throw new Error(`Host request was not confirmed: ${result.stderr.toString("utf8")}`);
266
+ }
267
+ return data.hosts;
268
+ }
242
269
  const UPDATE_CLI_PATH = fileURLToPath(new URL("../cli.js", import.meta.url));
243
270
  const UPDATE_CLIENT_RUNTIME_PATH = fileURLToPath(new URL("../controller/clientRuntime.js", import.meta.url));
244
271
  const UPDATE_INVENTORY_PATH = fileURLToPath(new URL("../controller/resourceInventoryLinux.js", import.meta.url));
@@ -19,6 +19,10 @@ export function taskRoleHostDiagnostic(host) {
19
19
  const delivery = snapshot.eventDelivery;
20
20
  return [
21
21
  `Provider Host=${snapshot.state}`,
22
+ `loaded Host=${snapshot.hostImplementation?.generation ?? "unreported"}`,
23
+ `loaded Endpoint=${snapshot.endpointImplementation?.generation ?? "unreported"}`,
24
+ snapshot.hostUpgrade === undefined ? undefined
25
+ : `Host upgrade=${snapshot.hostUpgrade.state} (${snapshot.hostUpgrade.requestId})${snapshot.hostUpgrade.detail ? `: ${snapshot.hostUpgrade.detail}` : ""}`,
22
26
  `terminal reconciliation=${snapshot.controlCapabilities?.includes("reconcile-turn") ? "supported" : "unreported/unsupported"}`,
23
27
  delivery === undefined ? "event delivery=unreported" :
24
28
  `pending facts=${delivery.pending}; native terminals awaiting acknowledgement=${delivery.pendingTerminals}`,
@@ -1,4 +1,5 @@
1
1
  import { accessSync, constants, lstatSync, readFileSync, realpathSync } from "node:fs";
2
+ import { access, lstat, readFile, realpath } from "node:fs/promises";
2
3
  import { join, resolve } from "node:path";
3
4
  import { fileURLToPath } from "node:url";
4
5
  export const BUILTIN_YUI_SKILLS = new Set([
@@ -41,9 +42,39 @@ export function checkCurrentSkills(home, ids) {
41
42
  }
42
43
  });
43
44
  }
45
+ /** Advisory lifecycle observation uses asynchronous filesystem I/O; it neither
46
+ * blocks a state transition nor substitutes for the exact per-input check. */
47
+ export async function checkCurrentSkillsAsync(home, ids, signal) {
48
+ const skills = [];
49
+ for (const id of new Set(ids)) {
50
+ signal.throwIfAborted();
51
+ const path = currentSkillPath(home, id);
52
+ const versionPath = join(path, "VERSION");
53
+ try {
54
+ for (const file of [join(path, "SKILL.md"), versionPath]) {
55
+ if (await realpath(file) !== resolve(file) || !(await lstat(file)).isFile()) {
56
+ throw new Error(`Skill files must be regular files without symlinks: ${file}.`);
57
+ }
58
+ await access(file, constants.R_OK);
59
+ }
60
+ if ((await lstat(versionPath)).size > 128)
61
+ throw new Error("VERSION exceeds 128 bytes.");
62
+ const version = (await readFile(versionPath, { encoding: "utf8", signal })).trim();
63
+ if (!/^[1-9][0-9]{0,8}$/u.test(version))
64
+ throw new Error("VERSION must contain a positive internal integer.");
65
+ skills.push({ id, path, version, versionPath });
66
+ }
67
+ catch (error) {
68
+ throw new Error(`Current Skill ${id} is missing, unreadable or invalid at ${path}. `
69
+ + `${error instanceof Error ? error.message : String(error)}`, { cause: error });
70
+ }
71
+ }
72
+ return skills;
73
+ }
44
74
  export const SKILL_CHECK_COMMAND = "yui session skills --json";
45
75
  export const SKILL_REUSE_RULE = "The managed Host checks current Skill versions/availability before each input; "
46
- + "do not repeat an explicit Host check. Without that notice, check through the Manifest's command. "
76
+ + "unchanged inputs carry no notice or marker, and do not require a duplicate check. "
77
+ + "For unmediated clients or uncertain current input routing, check through the Manifest's command; conversation reuse alone does not establish Host mediation. Keep routine checks internal, not user-facing progress. "
47
78
  + "Reuse unchanged full instructions in context; messages alone require no reload. "
48
79
  + "Read affected entries/references fully on change or localized loss. On first entry, recovery or lost context, "
49
80
  + "rebuild the current working set from applicable Skill entry points and action-relevant references, not all past procedures. "
@@ -1,9 +1,10 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { chmodSync, readFileSync } from "node:fs";
3
+ import { readFile } from "node:fs/promises";
3
4
  import { join, resolve } from "node:path";
4
5
  import { writeTextFileAtomically } from "../storage/durableFile.js";
5
6
  import { validateSkillPackageRef } from "./skillPackage.js";
6
- import { checkCurrentSkills, SKILL_CHECK_COMMAND, SKILL_REUSE_RULE } from "./currentSkills.js";
7
+ import { checkCurrentSkills, checkCurrentSkillsAsync, SKILL_CHECK_COMMAND, SKILL_REUSE_RULE } from "./currentSkills.js";
7
8
  import { SESSION_BOOTSTRAP_MANIFEST_SCHEMA_VERSION, SESSION_CONTEXT_PROTOCOL, sessionManifestCompatibilityDigest } from "./sessionProtocolIdentity.js";
8
9
  export { SESSION_BOOTSTRAP_MANIFEST_SCHEMA_VERSION, SESSION_CONTEXT_PROTOCOL, sessionManifestCompatibilityDigest } from "./sessionProtocolIdentity.js";
9
10
  /** Portable read pointers accompany every managed input, including the first
@@ -22,11 +23,11 @@ export function withSessionContextPointer(text, environment) {
22
23
  * Unmediated/native clients retain the explicit diagnostic fallback. */
23
24
  export function sessionContextInstructions(manifestPath, digest) {
24
25
  return `Yui Session Manifest: ${manifestPath}${digest === undefined ? "" : ` (digest ${digest})`}. `
25
- + "For inputs carrying the Yui Host Skill-check notice, engineering has checked current versions; do not repeat that check. "
26
- + `Otherwise run its contextProtocol.skillCheckCommand (fallback: ${SKILL_CHECK_COMMAND}). `
26
+ + "The managed Host checks Skills internally and injects guidance only when loading is needed; absence of a notice does not require another check. "
27
+ + `For unmediated clients or uncertain current input routing, run its contextProtocol.skillCheckCommand (fallback: ${SKILL_CHECK_COMMAND}); conversation reuse alone does not establish Host mediation. `
27
28
  + "On first entry or lost instructions, read the current applicable Skill entry points, then only references needed for the current action. "
28
29
  + "On change reload affected instructions. Compaction does not require restoring every previously used procedure. "
29
- + "Preserve the Manifest Role profile; metadata never proves loading or memory.";
30
+ + "Preserve the Manifest Role profile; metadata never proves loading or memory. Keep routine role, version and empty-inbox checks internal.";
30
31
  }
31
32
  /** The caller must already pass the ordinary managed Session/CLI/Home checks.
32
33
  * Old manifests remain immutable audit records, while this read resolves their
@@ -40,6 +41,15 @@ export function checkSessionSkills(home, manifestPath) {
40
41
  historicalPackagesAreAuditOnly: true
41
42
  };
42
43
  }
44
+ export async function checkSessionSkillsAsync(home, manifestPath, signal) {
45
+ const manifest = validateSessionBootstrapManifest(JSON.parse(await readFile(resolve(manifestPath), { encoding: "utf8", signal })));
46
+ return {
47
+ skills: await checkCurrentSkillsAsync(home, manifest.skills.map(skill => skill.id), signal),
48
+ roleProfileRef: manifest.roleProfileRef,
49
+ rule: SKILL_REUSE_RULE,
50
+ historicalPackagesAreAuditOnly: true
51
+ };
52
+ }
43
53
  /** Read back one immutable Session Manifest and verify its content digest. */
44
54
  export function readSessionBootstrapManifest(path) {
45
55
  const source = resolve(path);
@@ -50,6 +60,9 @@ export function readSessionBootstrapManifest(path) {
50
60
  catch (error) {
51
61
  throw new Error(`Session Manifest is unreadable: ${source}.`, { cause: error });
52
62
  }
63
+ return validateSessionBootstrapManifest(parsed);
64
+ }
65
+ function validateSessionBootstrapManifest(parsed) {
53
66
  if (parsed === null || typeof parsed !== "object") {
54
67
  throw new Error("Session Manifest is invalid.");
55
68
  }
@@ -1,4 +1,26 @@
1
- import { checkSessionSkills } from "./sessionBootstrapManifest.js";
1
+ import { checkSessionSkills, checkSessionSkillsAsync } from "./sessionBootstrapManifest.js";
2
+ /** Early advisory checks do not commit notices or assert model memory. The
3
+ * next input still validates its exact metadata and emits only its own delta.
4
+ * Invalid metadata may reject that input, never a Session state transition. */
5
+ export function createSessionSkillLifecycleCheck(home, environment) {
6
+ return {
7
+ id: "session-skills",
8
+ run: async (transition, signal) => {
9
+ if (!["connected", "interactive-connected", "settled"].includes(transition.event.type))
10
+ return;
11
+ const manifest = environment().YUI_SESSION_MANIFEST;
12
+ if (!manifest || signal.aborted)
13
+ return;
14
+ try {
15
+ await checkSessionSkillsAsync(home, manifest, signal);
16
+ }
17
+ catch (error) {
18
+ if (!signal.aborted)
19
+ throw error;
20
+ }
21
+ }
22
+ };
23
+ }
2
24
  /** Host-local last accepted metadata, never a model loading/memory receipt.
3
25
  * A new Host/attachment starts cold. No durable schema or input replay is added. */
4
26
  export function createSessionSkillNotice(home) {
@@ -10,9 +32,7 @@ export function createSessionSkillNotice(home) {
10
32
  prepare(text, environment, sessionKey, options = {}) {
11
33
  const manifest = environment.YUI_SESSION_MANIFEST;
12
34
  if (manifest === undefined)
13
- return { text, commit() { } };
14
- if (options.recovery)
15
- invalidate();
35
+ return { text, inputText: text, commit() { } };
16
36
  let current;
17
37
  try {
18
38
  current = checkSessionSkills(home, manifest);
@@ -21,7 +41,7 @@ export function createSessionSkillNotice(home) {
21
41
  invalidate();
22
42
  throw error; // Do not submit business input with an invalid current installation.
23
43
  }
24
- const key = JSON.stringify([sessionKey, manifest, current.roleProfileRef]);
44
+ const key = JSON.stringify([sessionKey, manifest, current.roleProfileRef, options.contextEpoch]);
25
45
  const skills = new Map(current.skills.map(skill => [skill.id, JSON.stringify({
26
46
  id: skill.id, path: skill.path, version: skill.version
27
47
  })]));
@@ -30,17 +50,18 @@ export function createSessionSkillNotice(home) {
30
50
  .map(([, value]) => value);
31
51
  const removed = !cold && [...previous.skills.keys()].some(id => !skills.has(id));
32
52
  // Every input is checked, but only a lifecycle/metadata event needs an
33
- // instruction-loading notice. This marker distinguishes Host-mediated
34
- // input from an unmediated client without restating the operating manual.
35
- const lines = ["Yui Host checked current Skills; no Agent check needed."];
53
+ // instruction-loading notice. Unchanged input is passed through exactly.
54
+ const lines = [];
36
55
  if (cold || changed.length > 0 || removed) {
37
- lines.push(`Yui Session Manifest: ${manifest}`, cold
38
- ? `First input or recovered attachment: read the exact Role profile at ${current.roleProfileRef.path} and the full current listed Skill entry points. Follow references only for the action you are taking, not to restore every previously used procedure. A Task role-profile projection is not this profile.`
39
- : "Skill metadata changed: load affected entries and applicable references; retain other complete instructions.", `Current Skill pointers: [${(removed ? [...skills.values()] : changed).join(",")}]`, "Metadata is not a loading receipt. Read each required file through EOF; if output is truncated, finish the missing ranges. Recover missing instructions for the current task, not the whole manual.");
56
+ lines.push(`Yui Session Manifest: ${manifest}`, "Host-managed Skill checks are active; unchanged input has no notice. Detached/unmediated clients must use the Manifest check fallback.", cold
57
+ ? `Read the exact Role profile at ${current.roleProfileRef.path} (not a Task projection) and all current Skill entry points below. Follow references only for the current action.`
58
+ : "Skill metadata changed: load affected entries and applicable references; retain other complete instructions.", `Current Skill pointers: [${(removed ? [...skills.values()] : changed).join(",")}]`, "Read required files through EOF. Metadata is not loaded instructions. Keep routine checks internal.");
40
59
  }
41
60
  const preparedGeneration = generation;
42
61
  return {
43
- text: `${lines.join("\n")}\n\n${text}`,
62
+ text: lines.length === 0 ? text : `${lines.join("\n")}\n\n${text}`,
63
+ inputText: text,
64
+ ...(lines.length === 0 ? {} : { context: lines.join("\n") }),
44
65
  commit() {
45
66
  if (generation !== preparedGeneration)
46
67
  return;
@@ -59,21 +59,31 @@ export async function assertFileTaskControllerStorageCompatible(home, options =
59
59
  export async function ensureFileTaskController(home, options = {}) {
60
60
  const call = options.call ?? callController;
61
61
  await waitForForeignHandover(home, options);
62
- try {
63
- const status = await call(home, "controller.status", {});
64
- assertCompatibleControllerStatus(status, options.expectedIdentity);
65
- return status;
66
- }
67
- catch (error) {
68
- if (!isUnavailable(error))
69
- throw error;
62
+ const timeoutMs = positive(options.startupTimeoutMs, STARTUP_TIMEOUT_MS, "startupTimeoutMs");
63
+ const pollMs = positive(options.pollIntervalMs, POLL_INTERVAL_MS, "pollIntervalMs");
64
+ const probeDeadline = Date.now() + timeoutMs;
65
+ for (;;) {
66
+ try {
67
+ const status = await call(home, "controller.status", {});
68
+ assertCompatibleControllerStatus(status, options.expectedIdentity);
69
+ return status;
70
+ }
71
+ catch (error) {
72
+ if (isUnavailable(error))
73
+ break;
74
+ // A closing endpoint may reset a read even after its owner exited.
75
+ // Retry only this observation: ambiguity never authorizes a spawn.
76
+ if (!(error instanceof ControllerClientError)
77
+ || error.code !== "CONTROLLER_DELIVERY_UNKNOWN"
78
+ || Date.now() >= probeDeadline)
79
+ throw error;
80
+ }
81
+ await delay(pollMs);
70
82
  }
71
83
  // A handover may have started after the first lock check and before the
72
84
  // failed discovery/socket call. Re-check before spawning so a managed
73
85
  // Session can never resurrect the old Controller during an update.
74
86
  await waitForForeignHandover(home, options);
75
- const timeoutMs = positive(options.startupTimeoutMs, STARTUP_TIMEOUT_MS, "startupTimeoutMs");
76
- const pollMs = positive(options.pollIntervalMs, POLL_INTERVAL_MS, "pollIntervalMs");
77
87
  const spawnController = options.spawnController ?? spawnDetachedFileTaskController;
78
88
  // A readiness timeout is not exit evidence. Reuse the observable startup
79
89
  // process for this exact Home rather than multiplying unready Controllers.
@@ -213,17 +223,26 @@ async function waitForControllerProcessExit(controller, options) {
213
223
  const timeoutMs = positive(options.shutdownTimeoutMs, CONTROLLER_SHUTDOWN_TIMEOUT_MS, "shutdownTimeoutMs");
214
224
  const pollMs = positive(options.pollIntervalMs, POLL_INTERVAL_MS, "pollIntervalMs");
215
225
  const deadline = Date.now() + timeoutMs;
216
- // This check is tri-state: unreadable OS identity throws rather than
217
- // collapsing an unknown/live process into the absence of a Controller match.
218
- while (processGenerationIsLive(controller.pid, controller.processStartIdentity)) {
219
- if (process.platform === "linux") {
220
- const observed = readLinuxProcessIdentity(controller.pid);
221
- // Preserve the existing physical-stop boundary: an exact zombie has
222
- // exited, even if its parent has not reaped the process table entry.
223
- if (observed?.startIdentity === controller.processStartIdentity && observed.state === "Z")
226
+ for (;;) {
227
+ let observationError;
228
+ try {
229
+ if (!processGenerationIsLive(controller.pid, controller.processStartIdentity))
224
230
  return;
231
+ if (process.platform === "linux") {
232
+ const observed = readLinuxProcessIdentity(controller.pid);
233
+ // An exact zombie has exited even before its parent reaps it.
234
+ if (observed?.startIdentity === controller.processStartIdentity && observed.state === "Z")
235
+ return;
236
+ }
237
+ }
238
+ catch (error) {
239
+ // OS identity can disappear before kill(pid, 0) reports absence.
240
+ // Keep observing within the existing stop budget, never infer exit.
241
+ observationError = error;
225
242
  }
226
243
  if (Date.now() >= deadline) {
244
+ if (observationError !== undefined)
245
+ throw observationError;
227
246
  throw new ControllerClientError("CONTROLLER_TIMEOUT", `Controller process ${controller.pid} did not exit within ${timeoutMs} ms.`);
228
247
  }
229
248
  await delay(pollMs);
@@ -14,7 +14,7 @@ import { KeyedWorkQueue } from "../coordination/keyedWorkQueue.js";
14
14
  import { MailboxScheduler } from "../coordination/mailboxScheduler.js";
15
15
  import { nearestDeadlineBatch } from "../coordination/deadlineScheduler.js";
16
16
  import { mailboxHasWork, nextPendingBatch } from "../coordination/workMailbox.js";
17
- import { hasRuntimeCleanupObligation } from "../runtime/lifecycleReservation.js";
17
+ import { hasRuntimeCleanupObligation, runtimeCleanupDisposition } from "../runtime/lifecycleReservation.js";
18
18
  import { formatTaskRecordReference } from "../task/taskRecordReference.js";
19
19
  import { parseDurableJobAcknowledgeParams, parseDurableJobCancelParams, parseDurableJobRefParams, parseDurableJobStartParams } from "./jobControl.js";
20
20
  const DEFAULT_RECONCILIATION_INTERVAL_MS = reconciliationIntervalMilliseconds();
@@ -211,6 +211,12 @@ async function processSelectedRoleRuntimeCleanups(store, delivery, lifecycleHost
211
211
  const owner = runtimeOwner(target);
212
212
  const batchId = runtimeLifecycleBatchIdentity(target, mailbox);
213
213
  if (hasRuntimeCleanupObligation(mailbox)) {
214
+ // Upgrade is a graceful, exact-identity operation, never the destructive
215
+ // cleanup path. Its coordinator owns drain/restore; retain backpressure.
216
+ if (runtimeCleanupDisposition(mailbox) === "upgrade-host") {
217
+ markFailedRuntimeTarget(failedRoles, target);
218
+ continue;
219
+ }
214
220
  try {
215
221
  if (lifecycleHost === undefined) {
216
222
  throw new Error("Role runtime cleanup host is unavailable.");
@@ -698,6 +704,7 @@ export class FileTaskController {
698
704
  #providerRetry;
699
705
  #continuationReconciler;
700
706
  #hostTerminalRecovery;
707
+ #lifecycleChecks;
701
708
  #persistenceMetrics;
702
709
  #diagnosticMetrics;
703
710
  #current;
@@ -754,6 +761,7 @@ export class FileTaskController {
754
761
  this.#schedules = options.schedules;
755
762
  this.#continuationReconciler = options.continuationReconciler;
756
763
  this.#hostTerminalRecovery = options.hostTerminalRecovery;
764
+ this.#lifecycleChecks = options.lifecycleChecks;
757
765
  this.#globalInputDelivery = options.globalInputDelivery;
758
766
  this.#messageHealth = options.messageHealth;
759
767
  this.#providerRetry = options.providerRetry;
@@ -842,6 +850,7 @@ export class FileTaskController {
842
850
  return;
843
851
  if (this.#stopped)
844
852
  throw new Error("Controller runtime is stopped.");
853
+ this.#lifecycleChecks?.notify("startup");
845
854
  this.#requestBackgroundPump();
846
855
  this.#requestRuntimeObservation();
847
856
  // Arm the Operator lane once for work that was already pending when the
@@ -862,6 +871,7 @@ export class FileTaskController {
862
871
  }
863
872
  stop() {
864
873
  this.#stopped = true;
874
+ this.#lifecycleChecks?.close();
865
875
  this.#workspaceWaitAbort.abort(new Error("Controller stopped while waiting for Project maintenance."));
866
876
  this.#signalScheduler.stop();
867
877
  this.#operatorSignalScheduler.stop();
@@ -905,6 +915,7 @@ export class FileTaskController {
905
915
  if (this.#stopped)
906
916
  throw new Error("Controller runtime is stopped.");
907
917
  parseMailboxKey(key);
918
+ this.#lifecycleChecks?.notify("signal");
908
919
  if (key === "operator") {
909
920
  // Main lane owns durable Hook folding; the Operator lane is the sole
910
921
  // consumer of the Operator delivery mailbox.
@@ -1017,6 +1028,8 @@ export class FileTaskController {
1017
1028
  }
1018
1029
  }
1019
1030
  const firstRuntimeDrain = await this.#phases.observe("runtime-drain", "home", () => this.#drainRuntimeEvents());
1031
+ if (scope.kind === "full")
1032
+ this.#lifecycleChecks?.notify("periodic");
1020
1033
  if (scope.kind === "full" && this.#hostTerminalRecovery !== undefined) {
1021
1034
  await this.#observeMaintenance("Host terminal recovery", this.#hostTerminalRecovery);
1022
1035
  }
@@ -1258,6 +1271,8 @@ export class FileTaskController {
1258
1271
  ? await processor.drainAsync(this.#now())
1259
1272
  : processor.drain(this.#now());
1260
1273
  this.#lastRuntimeDrain = result;
1274
+ if (result.acknowledgedEventIds.length > 0)
1275
+ this.#lifecycleChecks?.notify("events-persisted");
1261
1276
  this.#runtimeDrainPasses += 1;
1262
1277
  const metrics = result.metrics ?? ZERO_DRAIN_METRICS;
1263
1278
  this.#runtimeListedEvents += metrics.listedEventCount;
@@ -1723,6 +1723,8 @@ export class FileSchedulerStoreAdapter {
1723
1723
  const disposition = runtimeCleanupDisposition(mailbox);
1724
1724
  if (mailbox === null || disposition === null)
1725
1725
  return false;
1726
+ if (disposition === "upgrade-host")
1727
+ return false;
1726
1728
  if (mailbox.processing !== null) {
1727
1729
  if (disposition !== "replace-session" && !mailbox.processing.batch.reasons.every(isRuntimeCleanupReason)) {
1728
1730
  return false;
@@ -0,0 +1,153 @@
1
+ import { createRuntimeBinding } from "../runtime/runtimeBinding.js";
2
+ import { normalizeRuntimeOwner } from "../runtime/runtimeOwner.js";
3
+ import { RuntimeHostContentionError } from "../runtime/ports.js";
4
+ const states = ["unavailable", "stopped", "starting", "running", "stopping"];
5
+ export const CONTROLLER_HOST_TRANSITIONS = {
6
+ launch: { from: ["unavailable", "stopped", "running"], to: "starting" },
7
+ launched: { from: ["starting"], to: "running" },
8
+ stop: { from: states, to: "stopping" },
9
+ "exit-sent": { from: ["stopping"], to: "stopping" },
10
+ stopped: { from: ["stopping"], to: "stopped" },
11
+ failed: { from: ["starting", "stopping"], to: "unavailable" },
12
+ observed: { from: states, to: (current, observed) => current === "stopping" && (observed.state === "running" || observed.state === "starting")
13
+ ? "stopping" : observed.state }
14
+ };
15
+ /** All physical effects still require their existing durable caller authority.
16
+ * This process-local FSM gates competing effects at the shared Host port.
17
+ * Recovery observes current custody; it never invents a launch or replays one.
18
+ * Observation revisions reject late reads, not external process identities:
19
+ * exact OS/native identities remain owned by SessionOwner/Host fences. */
20
+ export function createControllerHostLifecycle(raw, hooks = {}) {
21
+ const entries = new Map();
22
+ let nextOperation = 0;
23
+ const entryFor = (input) => {
24
+ const owner = normalizeRuntimeOwner(input);
25
+ const key = JSON.stringify(owner);
26
+ let entry = entries.get(key);
27
+ if (!entry) {
28
+ entry = { owner: Object.freeze(owner), state: "unavailable", revision: 0 };
29
+ entries.set(key, entry);
30
+ }
31
+ return entry;
32
+ };
33
+ const send = (entry, event, observed, binding) => {
34
+ const rule = CONTROLLER_HOST_TRANSITIONS[event];
35
+ if (!rule.from.includes(entry.state))
36
+ throw new RuntimeHostContentionError("previous-process", `Host lifecycle cannot ${event} from ${entry.state}.`);
37
+ const to = typeof rule.to === "function" ? rule.to(entry.state, observed) : rule.to;
38
+ const nativeSessionId = to === "stopped" ? undefined
39
+ : binding?.nativeSessionId ?? observed?.nativeSessionId ?? entry.nativeSessionId;
40
+ const bindingId = to === "stopped" ? undefined
41
+ : observed?.nativeSessionId && observed.nativeSessionId !== entry.nativeSessionId
42
+ ? undefined : binding?.id ?? entry.bindingId;
43
+ if (event === "observed" && to === entry.state && nativeSessionId === entry.nativeSessionId)
44
+ return;
45
+ const transition = Object.freeze({ owner: entry.owner, from: entry.state, to, event,
46
+ revision: entry.revision + 1, operationId: entry.operationId, nativeSessionId, bindingId });
47
+ hooks.before?.notify(transition);
48
+ entry.state = to;
49
+ entry.revision = transition.revision;
50
+ entry.nativeSessionId = nativeSessionId;
51
+ entry.bindingId = bindingId;
52
+ hooks.after?.notify(transition);
53
+ };
54
+ const observe = (entry, revision, inspection) => {
55
+ if (entry.revision === revision && entry.operationId === undefined)
56
+ send(entry, "observed", inspection);
57
+ return inspection;
58
+ };
59
+ const effect = async (owner, event, run) => {
60
+ const entry = entryFor(owner);
61
+ if (entry.operationId !== undefined)
62
+ throw new RuntimeHostContentionError("previous-process", `Host lifecycle already has an in-flight operation for ${owner.roleName}.`);
63
+ // Check state before reserving the operation; a rejected edge owns nothing.
64
+ if (!CONTROLLER_HOST_TRANSITIONS[event].from.includes(entry.state))
65
+ throw new RuntimeHostContentionError("previous-process", `Host lifecycle cannot ${event} from ${entry.state}.`);
66
+ entry.operationId = ++nextOperation;
67
+ send(entry, event);
68
+ try {
69
+ return await run(entry);
70
+ }
71
+ catch (error) {
72
+ send(entry, "failed");
73
+ throw error;
74
+ }
75
+ finally {
76
+ entry.operationId = undefined;
77
+ }
78
+ };
79
+ const inspectOwner = async (owner) => {
80
+ const entry = entryFor(owner), revision = entry.revision;
81
+ return observe(entry, revision, await raw.inspectOwner(owner));
82
+ };
83
+ const launch = async (request, before) => {
84
+ await inspectOwner(request.owner);
85
+ return effect(request.owner, "launch", async (entry) => {
86
+ const binding = createRuntimeBinding(request.mode === "new"
87
+ ? await raw.start(request, before) : await raw.restore(request, before));
88
+ if (JSON.stringify(normalizeRuntimeOwner(binding.owner)) !== JSON.stringify(entry.owner)
89
+ || binding.agentId !== request.agentId || binding.adapterId !== request.adapterId
90
+ || request.mode === "resume" && binding.nativeSessionId !== request.nativeSessionId) {
91
+ throw new Error("Host lifecycle launch handshake has mismatched owner/native identity.");
92
+ }
93
+ send(entry, "launched", undefined, binding);
94
+ return binding;
95
+ });
96
+ };
97
+ const port = {
98
+ start: launch, restore: launch, inspectOwner,
99
+ inspect: async (binding) => {
100
+ const entry = entryFor(binding.owner), revision = entry.revision;
101
+ const inspection = await raw.inspect(binding);
102
+ if (entry.bindingId && entry.bindingId !== binding.id
103
+ || entry.nativeSessionId && binding.nativeSessionId !== entry.nativeSessionId)
104
+ return inspection;
105
+ return observe(entry, revision, inspection);
106
+ },
107
+ ...(raw.inspectOwners === undefined ? {} : {
108
+ inspectOwners: async (owners) => {
109
+ const revisions = new Map(owners.map(owner => {
110
+ const entry = entryFor(owner);
111
+ return [entry, entry.revision];
112
+ }));
113
+ const results = await raw.inspectOwners(owners);
114
+ for (const { owner, inspection } of results) {
115
+ const entry = entryFor(owner), revision = revisions.get(entry);
116
+ if (revision !== undefined)
117
+ observe(entry, revision, inspection);
118
+ }
119
+ return results;
120
+ }
121
+ }),
122
+ stop: async (binding) => {
123
+ const current = entryFor(binding.owner);
124
+ if (current.nativeSessionId && binding.nativeSessionId !== current.nativeSessionId
125
+ || current.bindingId && binding.id !== current.bindingId) {
126
+ throw new Error("Host lifecycle stop carries a stale binding identity.");
127
+ }
128
+ return effect(binding.owner, "stop", async (entry) => {
129
+ await raw.stop(binding);
130
+ const observed = await raw.inspect(binding);
131
+ if (observed.state !== "stopped")
132
+ throw new Error("Host lifecycle stop has no physical exit proof.");
133
+ send(entry, "stopped");
134
+ });
135
+ },
136
+ stopOwner: owner => effect(owner, "stop", async (entry) => {
137
+ const stopped = await (hooks.stopOwner ? hooks.stopOwner(owner) : raw.stopOwner(owner));
138
+ send(entry, stopped ? "stopped" : "failed");
139
+ return stopped;
140
+ })
141
+ };
142
+ return {
143
+ port,
144
+ snapshot: (owner) => Object.freeze({ ...entryFor(owner) }),
145
+ // Graceful exit acknowledgement is not exit proof. A later physical
146
+ // observation moves stopping -> stopped; no timer or automatic replay.
147
+ requestExit: (owner, run) => effect(owner, "stop", async (entry) => {
148
+ const result = await run();
149
+ send(entry, "exit-sent");
150
+ return result;
151
+ })
152
+ };
153
+ }