@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 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`, so every
931
- instance identity that Yui derives from `YUI_HOME`—Controller socket, tmux
932
- server, and state—stays separate from any other checkout or the global install.
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" && child.name === "identity"));
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;
@@ -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. Keep Controller ownership in this same owner by
265
- // using the CLI's structured lifecycle commands; tests can replace these
266
- // seams with deterministic fakes without touching a real Controller.
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
- const current = await readOptionalControllerStatus(home, call);
231
- const previousPid = controllerPid(current);
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(home)) {
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 = controllerSocketPath(home);
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 (existsSync(expectedControllerSocketPath)
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 { storageStatus: schema.status, roles };
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(home),
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
+ }