@zq-silk/yui 0.6.6 → 0.6.8
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/README.md +8 -4
- package/dist/cli/commandCatalog.js +6 -1
- package/dist/cli/invocationRouter.js +2 -1
- package/dist/cli/updatePorts.js +63 -9
- package/dist/cli.js +23 -0
- package/dist/controller/clientRuntime.js +26 -3
- package/dist/controller/resourceCleanupLinux.js +2 -1
- package/dist/controller/resourceInventory.js +4 -2
- package/dist/controller/resourceInventoryLinux.js +62 -5
- package/dist/controller/runtime.js +2 -1
- package/dist/controller/updateReconciliation.js +113 -0
- package/dist/core/controllerClient.js +141 -7
- package/dist/core/controllerEndpoint.js +18 -30
- package/dist/core/controllerProcessIdentity.js +56 -0
- package/dist/core/controllerServer.js +97 -9
- package/dist/core/homeFilesystemIdentity.js +24 -0
- package/dist/core/protocol.js +68 -5
- package/dist/integration/gitIntegrationService.js +3 -3
- package/dist/repository/homeIdentity.js +7 -3
- package/dist/resources/liveReferences.js +35 -2
- package/dist/storage/compatibleTaskStore.js +36 -1
- package/dist/storage/sqliteStore.js +25 -1
- package/dist/storage/taskStore.js +48 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -735,7 +735,7 @@ process cleanup explicitly, and revalidates process, tmux pane, and socket
|
|
|
735
735
|
identity immediately before acting. Partial failures are reported without
|
|
736
736
|
hiding the resources that remain. Use `--all` to include discovered Yui homes.
|
|
737
737
|
|
|
738
|
-
`controller restart` replaces the Controller process and its scheduler/socket services with the currently installed Yui version. It does not stop or restart managed tmux/Agent sessions.
|
|
738
|
+
`controller restart` replaces the Controller process and its scheduler/socket services with the currently installed Yui version. It can recover a lost discovery record only when the old process still matches the current UID, Controller entrypoint, physical Home, PID, and process-start identity. It does not stop or restart managed tmux/Agent sessions.
|
|
739
739
|
|
|
740
740
|
Successful `setup`, `upgrade`, and `update` commands ensure that the current
|
|
741
741
|
Home has a running Controller, starting one when the Home was previously idle.
|
|
@@ -927,9 +927,13 @@ make install-local
|
|
|
927
927
|
|
|
928
928
|
`make install-local` writes a self-contained launcher at `output/dev/bin/yui`
|
|
929
929
|
and never touches the user-level `yui` command. The launcher resolves its own
|
|
930
|
-
checkout and defaults `YUI_HOME` to this checkout's `output/dev/home
|
|
931
|
-
|
|
932
|
-
|
|
930
|
+
checkout and defaults `YUI_HOME` to this checkout's `output/dev/home`. The
|
|
931
|
+
Controller socket is derived from that Home's durable `homeId` at the fixed
|
|
932
|
+
Linux path `/tmp/yui-<uid>/<homeId>.sock`; discovery also binds the physical
|
|
933
|
+
Home directory, so caller `TMPDIR`, path aliases, and copied runtime records
|
|
934
|
+
cannot redirect control requests. The tmux server namespace and state remain
|
|
935
|
+
scoped to the selected Home, so the checkout stays separate from other
|
|
936
|
+
checkouts and the global install.
|
|
933
937
|
It is idempotent, so re-run it after pulling new code (then run
|
|
934
938
|
`./output/dev/bin/yui controller restart` if a Controller is already running).
|
|
935
939
|
Call the launcher by its absolute path for a stable per-checkout entry point;
|
|
@@ -849,7 +849,7 @@ export const ROOT_COMMAND = buildNode({
|
|
|
849
849
|
sections: [{
|
|
850
850
|
id: "lifecycle",
|
|
851
851
|
title: "Commands",
|
|
852
|
-
entries: ["status", "cleanup", "identity", "stop", "restart"]
|
|
852
|
+
entries: ["status", "cleanup", "identity", "live-identity", "stop", "restart"]
|
|
853
853
|
}],
|
|
854
854
|
children: [
|
|
855
855
|
{
|
|
@@ -869,6 +869,11 @@ export const ROOT_COMMAND = buildNode({
|
|
|
869
869
|
summary: "Read the stable runtime identity receipt (build, backend, worker).",
|
|
870
870
|
hidden: true
|
|
871
871
|
},
|
|
872
|
+
{
|
|
873
|
+
name: "live-identity",
|
|
874
|
+
summary: "Read the authenticated live Controller launch identity.",
|
|
875
|
+
hidden: true
|
|
876
|
+
},
|
|
872
877
|
{ name: "stop", summary: "Stop the Controller." },
|
|
873
878
|
{ name: "restart", summary: "Restart internal services without stopping tmux sessions." }
|
|
874
879
|
]
|
|
@@ -33,7 +33,8 @@ function resolveExecutionPath(args) {
|
|
|
33
33
|
const internalExecutable = child !== undefined && ((node === ROOT_COMMAND && child.name === "internal")
|
|
34
34
|
|| (node.path.join(" ") === "yui completion" && child.name === "candidates")
|
|
35
35
|
|| (node.path.join(" ") === "yui task run" && child.name === "checkpoint")
|
|
36
|
-
|| (node.path.join(" ") === "yui controller"
|
|
36
|
+
|| (node.path.join(" ") === "yui controller"
|
|
37
|
+
&& (child.name === "identity" || child.name === "live-identity")));
|
|
37
38
|
if (child === undefined || (child.hidden && !internalExecutable)) {
|
|
38
39
|
if (node.kind === "hybrid" && node.acceptsArguments)
|
|
39
40
|
break;
|
package/dist/cli/updatePorts.js
CHANGED
|
@@ -261,10 +261,12 @@ export function createUpdatePorts(environment, spawn = spawnSync, stagingRoot =
|
|
|
261
261
|
}
|
|
262
262
|
},
|
|
263
263
|
// `runUpdate` is intentionally synchronous because the npm/staged-binary
|
|
264
|
-
// ports use spawnSync.
|
|
265
|
-
//
|
|
266
|
-
//
|
|
264
|
+
// ports use spawnSync. A short-lived child owns the async, exactly fenced
|
|
265
|
+
// reconciliation first; lifecycle capture then uses structured live
|
|
266
|
+
// Controller commands. Tests can replace these seams with deterministic
|
|
267
|
+
// fakes without touching a real Controller.
|
|
267
268
|
controllerStatus(home) {
|
|
269
|
+
reconcileControllerResourcesForUpdate(home, environment, spawn);
|
|
268
270
|
return readControllerLifecycle(home, environment, spawn);
|
|
269
271
|
},
|
|
270
272
|
stopController(home, expectedPid) {
|
|
@@ -285,6 +287,7 @@ export function createUpdatePorts(environment, spawn = spawnSync, stagingRoot =
|
|
|
285
287
|
}
|
|
286
288
|
const UPDATE_CLI_PATH = fileURLToPath(new URL("../cli.js", import.meta.url));
|
|
287
289
|
const UPDATE_CLIENT_RUNTIME_PATH = fileURLToPath(new URL("../controller/clientRuntime.js", import.meta.url));
|
|
290
|
+
const UPDATE_CONTROLLER_RECONCILIATION_PATH = fileURLToPath(new URL("../controller/updateReconciliation.js", import.meta.url));
|
|
288
291
|
/**
|
|
289
292
|
* Capture running state plus an authenticated exact process identity before
|
|
290
293
|
* stopping. Public `controller status` is an inventory and intentionally
|
|
@@ -330,18 +333,18 @@ function readControllerLifecycle(home, environment, spawn, cliBinary) {
|
|
|
330
333
|
if (current !== undefined) {
|
|
331
334
|
throw new Error("Controller inventory is current but has no process proof; treating ownership as unknown-active.");
|
|
332
335
|
}
|
|
333
|
-
return proveControllerAbsent(home, environment, spawn);
|
|
336
|
+
return proveControllerAbsent(home, environment, spawn, cliBinary);
|
|
334
337
|
}
|
|
335
|
-
const identity = parseControllerIdentity(runControllerCommand(home, environment, spawn, "identity", cliBinary));
|
|
338
|
+
const identity = parseControllerIdentity(runControllerCommand(home, environment, spawn, "live-identity", cliBinary));
|
|
336
339
|
return {
|
|
337
340
|
running: true,
|
|
338
341
|
...(isPositivePid(processInfo.pid) ? { pid: processInfo.pid } : {}),
|
|
339
342
|
identity
|
|
340
343
|
};
|
|
341
344
|
}
|
|
342
|
-
function proveControllerAbsent(home, environment, spawn) {
|
|
345
|
+
function proveControllerAbsent(home, environment, spawn, cliBinary) {
|
|
343
346
|
try {
|
|
344
|
-
runControllerCommand(home, environment, spawn, "identity");
|
|
347
|
+
runControllerCommand(home, environment, spawn, "live-identity", cliBinary);
|
|
345
348
|
}
|
|
346
349
|
catch (error) {
|
|
347
350
|
if (controllerErrorCode(error) === "CONTROLLER_NOT_RUNNING") {
|
|
@@ -349,7 +352,7 @@ function proveControllerAbsent(home, environment, spawn) {
|
|
|
349
352
|
}
|
|
350
353
|
throw new Error(`Controller absence could not be authenticated: ${messageOf(error)}`, { cause: error });
|
|
351
354
|
}
|
|
352
|
-
throw new Error("Controller identity is reachable but inventory is currentless; treating ownership as unknown-active.");
|
|
355
|
+
throw new Error("A live Controller identity is reachable but inventory is currentless; treating ownership as unknown-active.");
|
|
353
356
|
}
|
|
354
357
|
function stopControllerForUpdate(home, expectedPid, environment, spawn) {
|
|
355
358
|
// Parent update owns this lifecycle boundary. Invoke the runtime client
|
|
@@ -464,7 +467,7 @@ function restartControllerForUpdate(home, environment, spawn, activatedBinary, a
|
|
|
464
467
|
const replacementPid = parseReplacementPid(restart.data);
|
|
465
468
|
let identityFailure;
|
|
466
469
|
try {
|
|
467
|
-
const identity = parseControllerIdentity(runControllerCommand(home, environment, spawn, "identity", activatedBinary));
|
|
470
|
+
const identity = parseControllerIdentity(runControllerCommand(home, environment, spawn, "live-identity", activatedBinary));
|
|
468
471
|
assertActivatedControllerIdentity(identity, activatedBinary, activatedVersion);
|
|
469
472
|
}
|
|
470
473
|
catch (error) {
|
|
@@ -586,6 +589,57 @@ function runControllerCommand(home, environment, spawn, method, cliBinary) {
|
|
|
586
589
|
}
|
|
587
590
|
return parsed.data;
|
|
588
591
|
}
|
|
592
|
+
function reconcileControllerResourcesForUpdate(home, environment, spawn) {
|
|
593
|
+
const helper = [
|
|
594
|
+
"const values = process.argv.slice(1);",
|
|
595
|
+
"const home = values.pop();",
|
|
596
|
+
"const reconciliationModule = values.pop();",
|
|
597
|
+
"(async () => {",
|
|
598
|
+
" const { reconcileControllerResourcesForUpdate } = await import(reconciliationModule);",
|
|
599
|
+
" const data = await reconcileControllerResourcesForUpdate(home, process.env);",
|
|
600
|
+
" process.stdout.write(JSON.stringify({ ok: true, data }));",
|
|
601
|
+
"})().catch((error) => {",
|
|
602
|
+
" const message = error instanceof Error ? error.message : String(error);",
|
|
603
|
+
" process.stderr.write(JSON.stringify({ ok: false, code: 'RUNTIME_ERROR', message }));",
|
|
604
|
+
" process.exitCode = 5;",
|
|
605
|
+
"});"
|
|
606
|
+
].join(" ");
|
|
607
|
+
const result = spawn(process.execPath, ["-e", helper, UPDATE_CONTROLLER_RECONCILIATION_PATH, home], { cwd: process.cwd(), env: { ...environment, YUI_HOME: home }, shell: false });
|
|
608
|
+
if (result.error !== undefined || result.status !== 0) {
|
|
609
|
+
const detail = structuredErrorMessage(result) ?? result.stderr.toString("utf8").trim();
|
|
610
|
+
throw new Error(`Controller reconciliation failed (exit ${result.status ?? "null"})${detail.length === 0 ? "." : `: ${detail}`}`);
|
|
611
|
+
}
|
|
612
|
+
let parsed;
|
|
613
|
+
try {
|
|
614
|
+
parsed = JSON.parse(result.stdout.toString("utf8"));
|
|
615
|
+
}
|
|
616
|
+
catch (error) {
|
|
617
|
+
throw new Error("Controller reconciliation returned an invalid structured result.", {
|
|
618
|
+
cause: error
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
if (!isRecord(parsed)
|
|
622
|
+
|| parsed.ok !== true
|
|
623
|
+
|| !isRecord(parsed.data)
|
|
624
|
+
|| !Array.isArray(parsed.data.cleaned)
|
|
625
|
+
|| parsed.data.cleaned.some((id) => typeof id !== "string")) {
|
|
626
|
+
throw new Error("Controller reconciliation returned an invalid structured result.");
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
function structuredErrorMessage(result) {
|
|
630
|
+
for (const buffer of [result.stderr, result.stdout]) {
|
|
631
|
+
try {
|
|
632
|
+
const value = JSON.parse(buffer.toString("utf8"));
|
|
633
|
+
if (isRecord(value) && typeof value.message === "string" && value.message.length > 0) {
|
|
634
|
+
return value.message;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
catch {
|
|
638
|
+
// Fall back to the child's raw stderr below.
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
return undefined;
|
|
642
|
+
}
|
|
589
643
|
function parseControllerIdentity(value) {
|
|
590
644
|
if (typeof value.executablePath !== "string"
|
|
591
645
|
|| value.executablePath.length === 0
|
package/dist/cli.js
CHANGED
|
@@ -301,6 +301,29 @@ export async function main() {
|
|
|
301
301
|
}
|
|
302
302
|
if (args[0] === "controller") {
|
|
303
303
|
const method = args[1];
|
|
304
|
+
if (method === "live-identity" && args.length === 2) {
|
|
305
|
+
try {
|
|
306
|
+
const identity = await callController(home, "controller.identity", {});
|
|
307
|
+
emit("", false, identity);
|
|
308
|
+
}
|
|
309
|
+
catch (error) {
|
|
310
|
+
if (!(error instanceof ControllerClientError))
|
|
311
|
+
throw error;
|
|
312
|
+
if (jsonOutput) {
|
|
313
|
+
process.stderr.write(`${JSON.stringify({
|
|
314
|
+
ok: false,
|
|
315
|
+
code: error.code,
|
|
316
|
+
message: error.message,
|
|
317
|
+
details: {}
|
|
318
|
+
})}\n`);
|
|
319
|
+
}
|
|
320
|
+
else {
|
|
321
|
+
process.stderr.write(`RUNTIME_ERROR: ${error.message}\n`);
|
|
322
|
+
}
|
|
323
|
+
process.exitCode = 5;
|
|
324
|
+
}
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
304
327
|
if (method === "identity" && args.length === 2) {
|
|
305
328
|
// Issue 02: the stable, read-only runtime identity receipt. It survives
|
|
306
329
|
// a Controller stop and answers build ID, package digest, backend, and
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
|
-
import { callController, readControllerDiscovery } from "../core/controllerClient.js";
|
|
3
|
+
import { callController, readControllerDiscovery, stopOrphanedFileTaskController, stopPreviousFileTaskController } from "../core/controllerClient.js";
|
|
4
4
|
import { FILE_TASK_CONTROLLER_PROTOCOL_VERSION } from "../core/protocol.js";
|
|
5
5
|
import { AGENT_OPERATIONAL_ENVIRONMENT_NAMES, nativeAgentEnvironmentNames, operationalAgentEnvironment, selectEnvironment, YUI_MANAGED_RUNTIME_ENVIRONMENT_NAMES } from "../agent/launchEnvironment.js";
|
|
6
6
|
import { openCompatibleFileTaskStore } from "../storage/compatibleTaskStore.js";
|
|
@@ -227,8 +227,26 @@ export async function restartFileTaskController(home, options = {}) {
|
|
|
227
227
|
const call = options.call ?? callController;
|
|
228
228
|
const shutdownTimeoutMs = positive(options.shutdownTimeoutMs, SHUTDOWN_TIMEOUT_MS, "shutdownTimeoutMs");
|
|
229
229
|
const pollMs = positive(options.pollIntervalMs, POLL_INTERVAL_MS, "pollIntervalMs");
|
|
230
|
-
|
|
231
|
-
|
|
230
|
+
let current = null;
|
|
231
|
+
let previousPid;
|
|
232
|
+
try {
|
|
233
|
+
current = await readOptionalControllerStatus(home, call);
|
|
234
|
+
previousPid = controllerPid(current);
|
|
235
|
+
}
|
|
236
|
+
catch (error) {
|
|
237
|
+
if (options.call !== undefined || !isInvalidDiscovery(error))
|
|
238
|
+
throw error;
|
|
239
|
+
// Protocol v3 is the immediately previous released Controller. Keep its
|
|
240
|
+
// parser and transport confined to this explicit replacement operation;
|
|
241
|
+
// no ordinary request can fall back to it.
|
|
242
|
+
const previous = await stopPreviousFileTaskController(home, shutdownTimeoutMs);
|
|
243
|
+
previousPid = previous.pid;
|
|
244
|
+
}
|
|
245
|
+
if (!controllerRunning(current) && options.call === undefined) {
|
|
246
|
+
const orphan = await stopOrphanedFileTaskController(home, shutdownTimeoutMs);
|
|
247
|
+
if (orphan !== undefined)
|
|
248
|
+
previousPid = orphan.pid;
|
|
249
|
+
}
|
|
232
250
|
if (controllerRunning(current)) {
|
|
233
251
|
await callFileTaskController(home, "controller.stop", {}, options);
|
|
234
252
|
const deadline = Date.now() + shutdownTimeoutMs;
|
|
@@ -555,6 +573,11 @@ function isDefinitelyNotRunning(error) {
|
|
|
555
573
|
const code = error.code;
|
|
556
574
|
return code === "CONTROLLER_NOT_RUNNING";
|
|
557
575
|
}
|
|
576
|
+
function isInvalidDiscovery(error) {
|
|
577
|
+
if (typeof error !== "object" || error === null || !("code" in error))
|
|
578
|
+
return false;
|
|
579
|
+
return error.code === "CONTROLLER_DISCOVERY_INVALID";
|
|
580
|
+
}
|
|
558
581
|
async function readOptionalControllerStatus(home, call) {
|
|
559
582
|
try {
|
|
560
583
|
return await call(home, "controller.status", {});
|
|
@@ -257,8 +257,9 @@ function assertOwnedArtifactPath(resource, environment) {
|
|
|
257
257
|
}
|
|
258
258
|
const home = resource.yuiHome;
|
|
259
259
|
if (home !== undefined
|
|
260
|
+
&& resource.homeId !== undefined
|
|
260
261
|
&& artifact.artifactKind === "controller-socket"
|
|
261
|
-
&& path === controllerSocketPath(
|
|
262
|
+
&& path === controllerSocketPath(resource.homeId)) {
|
|
262
263
|
return;
|
|
263
264
|
}
|
|
264
265
|
if (home !== undefined
|
|
@@ -174,7 +174,7 @@ export function buildControllerResourceInventory(facts) {
|
|
|
174
174
|
? homeFact.artifacts
|
|
175
175
|
: [discoveryArtifact, ...homeFact.artifacts.filter((artifact) => (artifact.path !== discoveryArtifact.path))];
|
|
176
176
|
for (const artifact of artifacts) {
|
|
177
|
-
resources.push(artifactResource(artifact, yuiHome, homeFact.domain));
|
|
177
|
+
resources.push(artifactResource(artifact, yuiHome, homeFact.domain, homeFact.homeId));
|
|
178
178
|
}
|
|
179
179
|
}
|
|
180
180
|
for (const artifact of facts.globalArtifacts) {
|
|
@@ -186,6 +186,7 @@ export function buildControllerResourceInventory(facts) {
|
|
|
186
186
|
const owned = ordered.filter((resource) => resource.yuiHome === yuiHome);
|
|
187
187
|
return {
|
|
188
188
|
yuiHome,
|
|
189
|
+
...(home.homeId === undefined ? {} : { homeId: home.homeId }),
|
|
189
190
|
storageStatus: home.storageStatus,
|
|
190
191
|
resourceCount: owned.length,
|
|
191
192
|
liveProcessCount: uniqueProcesses(owned).length,
|
|
@@ -332,7 +333,7 @@ function processResource(input) {
|
|
|
332
333
|
...(input.domain === undefined ? {} : { domain: input.domain })
|
|
333
334
|
};
|
|
334
335
|
}
|
|
335
|
-
function artifactResource(artifact, yuiHome, domain) {
|
|
336
|
+
function artifactResource(artifact, yuiHome, domain, homeId) {
|
|
336
337
|
const stale = !artifact.active;
|
|
337
338
|
const baseDisposition = stale ? "safe" : "report-only";
|
|
338
339
|
const disposition = domainDisposition(baseDisposition, undefined, domain);
|
|
@@ -346,6 +347,7 @@ function artifactResource(artifact, yuiHome, domain) {
|
|
|
346
347
|
disposition,
|
|
347
348
|
reasonCode: domainReason(stale ? `stale-${artifact.artifactKind}` : `active-unmapped-${artifact.artifactKind}`, domain, disposition),
|
|
348
349
|
...(yuiHome === undefined ? {} : { yuiHome }),
|
|
350
|
+
...(homeId === undefined ? {} : { homeId }),
|
|
349
351
|
owner: yuiHome === undefined
|
|
350
352
|
? { kind: "none" }
|
|
351
353
|
: { kind: "controller-domain", yuiHome },
|
|
@@ -10,6 +10,7 @@ import { tmuxSocketDirectory, tmuxSocketEnvironment } from "../tmux/tmuxSocketEn
|
|
|
10
10
|
import { readControllerDiscovery } from "../core/controllerClient.js";
|
|
11
11
|
import { CONTROLLER_DISCOVERY_PATH } from "../core/protocol.js";
|
|
12
12
|
import { controllerSocketPath } from "../core/controllerEndpoint.js";
|
|
13
|
+
import { readHomeFilesystemId } from "../core/homeFilesystemIdentity.js";
|
|
13
14
|
import { buildControllerResourceInventory } from "./resourceInventory.js";
|
|
14
15
|
import { CONTROLLER_DOMAIN_PATH, EPHEMERAL_DOMAIN_GRACE_MS, ephemeralDomainFingerprint, readEphemeralDomainIdentity, readLinuxProcessStartIdentity } from "./domainIdentity.js";
|
|
15
16
|
const LINUX_PROCESS_SCAN_BATCH_SIZE = 64;
|
|
@@ -20,8 +21,7 @@ export async function scanControllerResourceInventory(options) {
|
|
|
20
21
|
const observedAt = (options.now ?? (() => new Date()))();
|
|
21
22
|
const warnings = [];
|
|
22
23
|
const activeSockets = readActiveUnixSocketPaths(warnings);
|
|
23
|
-
const processes = (await listLinuxProcesses(warnings))
|
|
24
|
-
.filter(({ pid }) => pid !== process.pid);
|
|
24
|
+
const processes = normalizePhysicalHomeAliases((await listLinuxProcesses(warnings)).filter(({ pid }) => pid !== process.pid), currentHome);
|
|
25
25
|
const homes = new Set([currentHome]);
|
|
26
26
|
if (options.scope === "all") {
|
|
27
27
|
for (const process of processes) {
|
|
@@ -88,19 +88,23 @@ export async function scanControllerResourceInventory(options) {
|
|
|
88
88
|
|| (discovery.pid === process.pid
|
|
89
89
|
&& readLinuxProcessStartIdentity(process.pid) === discovery.processStartIdentity));
|
|
90
90
|
const discoveryPath = join(home, CONTROLLER_DISCOVERY_PATH);
|
|
91
|
-
const expectedControllerSocketPath =
|
|
91
|
+
const expectedControllerSocketPath = state.homeId === undefined
|
|
92
|
+
? undefined
|
|
93
|
+
: controllerSocketPath(state.homeId);
|
|
92
94
|
if (discovery.status === "valid"
|
|
93
95
|
&& !validDiscoveryProcess
|
|
94
96
|
&& existsSync(discoveryPath)) {
|
|
95
97
|
artifacts.push(fileArtifact(discoveryPath, "controller-discovery", false));
|
|
96
98
|
}
|
|
97
|
-
if (
|
|
99
|
+
if (expectedControllerSocketPath !== undefined
|
|
100
|
+
&& existsSync(expectedControllerSocketPath)
|
|
98
101
|
&& !activeSockets.has(expectedControllerSocketPath)
|
|
99
102
|
&& !validDiscoveryProcess) {
|
|
100
103
|
artifacts.push(fileArtifact(expectedControllerSocketPath, "controller-socket", false));
|
|
101
104
|
}
|
|
102
105
|
homeFacts.push({
|
|
103
106
|
yuiHome: home,
|
|
107
|
+
...(state.homeId === undefined ? {} : { homeId: state.homeId }),
|
|
104
108
|
exists: existsSync(home),
|
|
105
109
|
storageStatus: state.storageStatus,
|
|
106
110
|
discovery,
|
|
@@ -203,6 +207,9 @@ async function listLinuxProcesses(warnings) {
|
|
|
203
207
|
const args = splitNullDelimited(readFileSync(`/proc/${pid}/cmdline`));
|
|
204
208
|
const command = readFileSync(`/proc/${pid}/comm`, "utf8").trim();
|
|
205
209
|
const yuiHome = readYuiHome(pid);
|
|
210
|
+
const homeFilesystemId = yuiHome === undefined
|
|
211
|
+
? undefined
|
|
212
|
+
: optionalHomeFilesystemId(yuiHome);
|
|
206
213
|
const io = readProcessIo(pid);
|
|
207
214
|
result.push({
|
|
208
215
|
pid,
|
|
@@ -210,6 +217,7 @@ async function listLinuxProcesses(warnings) {
|
|
|
210
217
|
uid: processUid,
|
|
211
218
|
startIdentity: parsed.startIdentity,
|
|
212
219
|
...(yuiHome === undefined ? {} : { yuiHome }),
|
|
220
|
+
...(homeFilesystemId === undefined ? {} : { homeFilesystemId }),
|
|
213
221
|
kind: classifyRuntimeProcess(args, command),
|
|
214
222
|
command,
|
|
215
223
|
args,
|
|
@@ -227,6 +235,47 @@ async function listLinuxProcesses(warnings) {
|
|
|
227
235
|
});
|
|
228
236
|
return result;
|
|
229
237
|
}
|
|
238
|
+
/**
|
|
239
|
+
* Process environments retain their launch-time path spelling. Collapse
|
|
240
|
+
* symlink and bind-mount aliases by physical directory identity so inventory,
|
|
241
|
+
* update reconciliation, and cleanup all attribute them to one Home.
|
|
242
|
+
*/
|
|
243
|
+
function normalizePhysicalHomeAliases(processes, currentHome) {
|
|
244
|
+
const currentFilesystemId = optionalHomeFilesystemId(currentHome);
|
|
245
|
+
const representatives = new Map();
|
|
246
|
+
if (currentFilesystemId !== undefined) {
|
|
247
|
+
representatives.set(currentFilesystemId, currentHome);
|
|
248
|
+
}
|
|
249
|
+
for (const processFact of processes) {
|
|
250
|
+
const filesystemId = processFact.homeFilesystemId;
|
|
251
|
+
const yuiHome = processFact.yuiHome;
|
|
252
|
+
if (filesystemId === undefined
|
|
253
|
+
|| yuiHome === undefined
|
|
254
|
+
|| filesystemId === currentFilesystemId)
|
|
255
|
+
continue;
|
|
256
|
+
const existing = representatives.get(filesystemId);
|
|
257
|
+
if (existing === undefined || yuiHome.localeCompare(existing) < 0) {
|
|
258
|
+
representatives.set(filesystemId, yuiHome);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return processes.map((processFact) => {
|
|
262
|
+
const filesystemId = processFact.homeFilesystemId;
|
|
263
|
+
const representative = filesystemId === undefined
|
|
264
|
+
? undefined
|
|
265
|
+
: representatives.get(filesystemId);
|
|
266
|
+
return representative === undefined || representative === processFact.yuiHome
|
|
267
|
+
? processFact
|
|
268
|
+
: { ...processFact, yuiHome: representative };
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
function optionalHomeFilesystemId(home) {
|
|
272
|
+
try {
|
|
273
|
+
return readHomeFilesystemId(home);
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
return undefined;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
230
279
|
/** Cooperatively visits synchronous inventory entries in bounded turns. */
|
|
231
280
|
export async function forEachInEventLoopBatches(entries, batchSize, visit, timing = {}) {
|
|
232
281
|
if (!Number.isSafeInteger(batchSize) || batchSize < 1) {
|
|
@@ -372,6 +421,10 @@ async function inspectDiscovery(home, processes, activeSockets) {
|
|
|
372
421
|
const discovery = await readControllerDiscovery(home);
|
|
373
422
|
return {
|
|
374
423
|
status: "valid",
|
|
424
|
+
protocolVersion: discovery.protocolVersion,
|
|
425
|
+
homeId: discovery.homeId,
|
|
426
|
+
homeFilesystemId: discovery.homeFilesystemId,
|
|
427
|
+
controllerInstanceId: discovery.controllerInstanceId,
|
|
375
428
|
pid: discovery.pid,
|
|
376
429
|
processStartIdentity: discovery.processStartIdentity,
|
|
377
430
|
socketPath: discovery.socketPath,
|
|
@@ -659,7 +712,11 @@ function loadHomeState(home, warnings, options) {
|
|
|
659
712
|
}
|
|
660
713
|
}
|
|
661
714
|
}
|
|
662
|
-
return {
|
|
715
|
+
return {
|
|
716
|
+
storageStatus: schema.status,
|
|
717
|
+
homeId: store.getHomeIdentity().homeId,
|
|
718
|
+
roles
|
|
719
|
+
};
|
|
663
720
|
}
|
|
664
721
|
catch (error) {
|
|
665
722
|
warnings.push(`Cannot load runtime ownership for ${home}: ${message(error)}`);
|
|
@@ -84,6 +84,7 @@ export async function startFileTaskControllerRuntime(home, options = {}) {
|
|
|
84
84
|
// the same WAL db and serialize via BEGIN IMMEDIATE + busy_timeout.
|
|
85
85
|
? new SqliteTaskStore(home)
|
|
86
86
|
: openCompatibleFileTaskStore(home));
|
|
87
|
+
const homeId = store.getHomeIdentity().homeId;
|
|
87
88
|
// When the worker backend is active, the db-touching observer folds run in
|
|
88
89
|
// the worker (off the main event loop). The client is closed on shutdown.
|
|
89
90
|
const asyncStoreClient = useWorker
|
|
@@ -147,7 +148,7 @@ export async function startFileTaskControllerRuntime(home, options = {}) {
|
|
|
147
148
|
runtimeRoot: `${resolve(home)}.task-runtimes`,
|
|
148
149
|
controlPlane: {
|
|
149
150
|
yuiHome: home,
|
|
150
|
-
controllerSocketPath: controllerSocketPath(
|
|
151
|
+
controllerSocketPath: controllerSocketPath(homeId),
|
|
151
152
|
tmuxNamespace: yuiTmuxServerName(home),
|
|
152
153
|
globalInstallPaths: [process.execPath]
|
|
153
154
|
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { acquireHomeLifecycleLock } from "../core/controllerServer.js";
|
|
3
|
+
import { cleanControllerResource } from "./resourceCleanupLinux.js";
|
|
4
|
+
import { scanControllerResourceInventory } from "./resourceInventoryLinux.js";
|
|
5
|
+
const MAX_RECONCILIATION_PASSES = 4;
|
|
6
|
+
/**
|
|
7
|
+
* Reconcile only Controller-owned resources for the Home being updated.
|
|
8
|
+
*
|
|
9
|
+
* A current Controller is preserved for the update orchestrator's exact
|
|
10
|
+
* capture/stop handoff. Superseded/orphaned Controller processes and stale
|
|
11
|
+
* discovery/socket artifacts are cleaned using their existing process-start
|
|
12
|
+
* and inode fingerprints. Agent, tmux, app, and foreign-Home resources are
|
|
13
|
+
* deliberately outside this operation.
|
|
14
|
+
*/
|
|
15
|
+
export async function reconcileControllerResourcesForUpdate(home, environment = process.env) {
|
|
16
|
+
const resolvedHome = resolve(home);
|
|
17
|
+
const releaseLock = await acquireHomeLifecycleLock(resolvedHome, {
|
|
18
|
+
removeStaleOwner: true
|
|
19
|
+
});
|
|
20
|
+
const cleaned = new Set();
|
|
21
|
+
try {
|
|
22
|
+
for (let pass = 0; pass < MAX_RECONCILIATION_PASSES; pass += 1) {
|
|
23
|
+
const snapshot = await scanControllerResourceInventory({
|
|
24
|
+
currentHome: resolvedHome,
|
|
25
|
+
scope: "current",
|
|
26
|
+
environment
|
|
27
|
+
});
|
|
28
|
+
assertCertainSnapshot(snapshot, resolvedHome);
|
|
29
|
+
const resources = controllerResources(snapshot, resolvedHome);
|
|
30
|
+
const controllers = resources.filter(({ kind }) => kind === "controller");
|
|
31
|
+
const current = controllers.filter(({ state }) => state === "current");
|
|
32
|
+
if (current.length > 1) {
|
|
33
|
+
throw reconciliationBlocked(`multiple current Controllers were reported (${resourceLabels(current)})`);
|
|
34
|
+
}
|
|
35
|
+
const historical = controllers.filter(({ state }) => state !== "current");
|
|
36
|
+
const historicalCleanup = historical.filter(isCleanupEligible);
|
|
37
|
+
const unsafeHistorical = historical.filter((resource) => !isCleanupEligible(resource));
|
|
38
|
+
if (unsafeHistorical.length > 0) {
|
|
39
|
+
throw reconciliationBlocked(`historical Controller ownership is not safely cleanable (${resourceLabels(unsafeHistorical)})`);
|
|
40
|
+
}
|
|
41
|
+
const artifacts = resources.filter(isControllerArtifact);
|
|
42
|
+
const staleArtifactCleanup = artifacts.filter((resource) => (resource.state === "stale" && isCleanupEligible(resource)));
|
|
43
|
+
const unresolvedArtifacts = artifacts.filter((resource) => (!staleArtifactCleanup.includes(resource)));
|
|
44
|
+
// A corrupt discovery is conservatively marked active while an orphan
|
|
45
|
+
// Controller still exists. Remove the exactly fenced historical process
|
|
46
|
+
// first; the next scan can then reclassify and remove the stale artifact.
|
|
47
|
+
if (unresolvedArtifacts.length > 0 && historicalCleanup.length === 0) {
|
|
48
|
+
throw reconciliationBlocked(`a Controller artifact is active or ownership is unknown (${resourceLabels(unresolvedArtifacts)})`);
|
|
49
|
+
}
|
|
50
|
+
const candidates = [...historicalCleanup, ...staleArtifactCleanup];
|
|
51
|
+
if (candidates.length === 0) {
|
|
52
|
+
return { cleaned: [...cleaned] };
|
|
53
|
+
}
|
|
54
|
+
for (const candidate of candidates) {
|
|
55
|
+
try {
|
|
56
|
+
await cleanControllerResource(candidate, { environment });
|
|
57
|
+
cleaned.add(candidate.id);
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
// A concurrent exact cleanup that already reached the desired state
|
|
61
|
+
// is harmless. Anything still present or reclassified is a real
|
|
62
|
+
// ownership change and must remain a user-visible blocker.
|
|
63
|
+
const afterFailure = await scanControllerResourceInventory({
|
|
64
|
+
currentHome: resolvedHome,
|
|
65
|
+
scope: "current",
|
|
66
|
+
environment
|
|
67
|
+
});
|
|
68
|
+
assertCertainSnapshot(afterFailure, resolvedHome);
|
|
69
|
+
if (!controllerResources(afterFailure, resolvedHome).some(({ id }) => id === candidate.id)) {
|
|
70
|
+
cleaned.add(candidate.id);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
throw reconciliationBlocked(`resource ${candidate.id} changed or could not be cleaned: ${messageOf(error)}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
throw reconciliationBlocked("Controller resources did not converge after bounded cleanup");
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
await releaseLock();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function assertCertainSnapshot(snapshot, resolvedHome) {
|
|
84
|
+
if (snapshot.scope !== "current"
|
|
85
|
+
|| resolve(snapshot.currentHome) !== resolvedHome) {
|
|
86
|
+
throw reconciliationBlocked("the Controller inventory returned a mismatched Home or scope");
|
|
87
|
+
}
|
|
88
|
+
if (snapshot.warnings.length > 0) {
|
|
89
|
+
throw reconciliationBlocked(`the Controller inventory is uncertain: ${snapshot.warnings.join("; ")}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function controllerResources(snapshot, resolvedHome) {
|
|
93
|
+
return snapshot.resources.filter((resource) => (resource.yuiHome === resolvedHome
|
|
94
|
+
&& (resource.kind === "controller" || isControllerArtifact(resource))));
|
|
95
|
+
}
|
|
96
|
+
function isControllerArtifact(resource) {
|
|
97
|
+
return resource.kind === "artifact"
|
|
98
|
+
&& (resource.artifact?.artifactKind === "controller-discovery"
|
|
99
|
+
|| resource.artifact?.artifactKind === "controller-socket");
|
|
100
|
+
}
|
|
101
|
+
function isCleanupEligible(resource) {
|
|
102
|
+
return resource.disposition === "safe" || resource.disposition === "review";
|
|
103
|
+
}
|
|
104
|
+
function resourceLabels(resources) {
|
|
105
|
+
return resources.map((resource) => `${resource.id}:${resource.reasonCode}`).join(", ");
|
|
106
|
+
}
|
|
107
|
+
function reconciliationBlocked(reason) {
|
|
108
|
+
return new Error(`Automatic Controller reconciliation is blocked because ${reason}. `
|
|
109
|
+
+ "Run `yui controller status --verbose` and resolve only the reported current-Home resource before retrying.");
|
|
110
|
+
}
|
|
111
|
+
function messageOf(error) {
|
|
112
|
+
return error instanceof Error ? error.message : String(error);
|
|
113
|
+
}
|