@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.
- package/dist/cli/commandCatalog.js +2 -2
- package/dist/cli/updateCommand.js +20 -3
- package/dist/cli/updateOrchestrator.js +13 -0
- package/dist/cli/updatePorts.js +27 -0
- package/dist/commands/taskRoleRuntimeStatus.js +4 -0
- package/dist/context/currentSkills.js +32 -1
- package/dist/context/sessionBootstrapManifest.js +17 -4
- package/dist/context/sessionSkillNotice.js +33 -12
- package/dist/controller/clientRuntime.js +37 -18
- package/dist/controller/controller.js +16 -1
- package/dist/controller/fileSchedulerStoreAdapter.js +2 -0
- package/dist/controller/hostLifecycle.js +153 -0
- package/dist/controller/hostLifecycleChecks.js +27 -0
- package/dist/controller/hostUpgrade.js +344 -0
- package/dist/controller/runtime.js +67 -16
- package/dist/controller/sessionNotify.js +5 -46
- package/dist/execution/codexThreadNaming.js +54 -43
- package/dist/executor/fileRoleLaunchPlanner.js +18 -3
- package/dist/runtime/agentEndpoint.js +11 -1
- package/dist/runtime/agentEndpointIdentity.js +2 -0
- package/dist/runtime/agentHost.js +167 -42
- package/dist/runtime/agentHostImplementation.js +3 -1
- package/dist/runtime/codexAppServerRuntime.js +3 -0
- package/dist/runtime/codexInteractiveHost.js +62 -17
- package/dist/runtime/codexNativeAccess.js +1 -1
- package/dist/runtime/contextRecovery.js +15 -0
- package/dist/runtime/hostSessionLifecycle.js +83 -0
- package/dist/runtime/hostUpgradeGate.js +43 -0
- package/dist/runtime/lifecycleChecks.js +47 -0
- package/dist/runtime/lifecycleReservation.js +9 -1
- package/dist/runtime/nativeTitleReceipt.js +31 -0
- package/dist/runtime/nativeTitleSync.js +192 -0
- package/dist/runtime/providerRetry.js +4 -4
- package/dist/runtime/structuredProviderHost.js +42 -10
- package/dist/storage/sqliteSchema.js +20 -0
- package/dist/storage/storageVersions.js +1 -1
- package/docs/managed-turn-and-session-runtime.md +96 -1
- package/docs/managed-turn-and-session-runtime.zh-CN.md +70 -1
- package/docs/release-workflow.md +28 -0
- package/docs/release-workflow.zh-CN.md +20 -0
- package/docs/storage-baseline.md +10 -1
- package/docs/storage-baseline.zh-CN.md +5 -1
- package/docs/testing/verification-levels.md +2 -2
- package/docs/testing/verification-levels.zh-CN.md +2 -2
- package/package.json +1 -1
- package/skills/yui-runtime/SKILL.md +7 -2
- 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
|
|
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
|
-
"
|
|
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
|
}
|
package/dist/cli/updatePorts.js
CHANGED
|
@@ -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
|
-
+ "
|
|
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
|
-
+ "
|
|
26
|
-
+ `
|
|
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.
|
|
34
|
-
|
|
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
|
-
? `
|
|
39
|
-
: "Skill metadata changed: load affected entries and applicable references; retain other complete instructions.", `Current Skill pointers: [${(removed ? [...skills.values()] : changed).join(",")}]`, "
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
+
}
|