@zq-silk/yui 0.6.7 → 0.6.9

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;
@@ -1802,7 +1802,7 @@ function dispatchWork(args, store, options) {
1802
1802
  if (item.assignee === undefined) {
1803
1803
  throw usageError(`Work Item has no Task Role assignee: ${item.id}. `
1804
1804
  + `The Task Leader must run "yui task work update ${item.id} running", `
1805
- + "then execute it directly or create a native subagent in the Leader conversation.");
1805
+ + "then execute it directly or create native subagents in the Leader Session.");
1806
1806
  }
1807
1807
  if (expanding && taskActor(options, task.id) !== "leader") {
1808
1808
  throw usageError("Only the Task Leader may expand a running ExecutionGroup.");
@@ -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", {});
@@ -26,7 +26,8 @@ import { enqueueWork } from "../coordination/workMailboxQueue.js";
26
26
  import { RUNTIME_CLEANUP_REQUIRED_REASON, RUNTIME_LAUNCH_RESERVED_REASON, RUNTIME_LIFECYCLE_OWNER, hasRuntimeCleanupObligation, hasRuntimeLifecycleWork, isRuntimeLaunchReservation, runtimeLifecycleTarget } from "../runtime/lifecycleReservation.js";
27
27
  import { nativeSessionIdForLaunch } from "../runtime/preallocatedNativeSession.js";
28
28
  import { builtinAgentDriverRegistry } from "../runtime/builtinAgentDrivers.js";
29
- import { RUNTIME_OBSERVATION_TASK_EVENT, createRuntimeObservation, runtimeObservationFenceMatches, runtimeObservationFromTaskEvent, runtimeObservationTaskEventPayload } from "../runtime/runtimeObservation.js";
29
+ import { RUNTIME_OBSERVATION_TASK_EVENT, createRuntimeObservation, runtimeObservationFenceMatches, runtimeObservationFromTaskEvent, runtimeObservationRunFenceMatches, runtimeObservationTaskEventPayload } from "../runtime/runtimeObservation.js";
30
+ import { projectRuntimeTaskEvents } from "../runtime/runtimeProjection.js";
30
31
  /** Maps the authoritative FileTaskStore records to the scheduler's narrow port. */
31
32
  export class FileSchedulerStoreAdapter {
32
33
  store;
@@ -80,6 +81,13 @@ export class FileSchedulerStoreAdapter {
80
81
  const classification = this.classifyRuntimeTurnCompleted(completed);
81
82
  if (classification !== "apply")
82
83
  return classification;
84
+ if (this.runtimeTurnHasActiveNativeSubagents(input)) {
85
+ // This is an intermediate provider Turn boundary, not the end of the
86
+ // durable Yui Run. The provider will deliver child completion
87
+ // notifications as later native Turns in the same Run generation.
88
+ outcome = this.validateCanonicalRunObservation(input, now);
89
+ break;
90
+ }
83
91
  const result = this.observeRuntimeTurnCompleted(completed, now);
84
92
  outcome = result.disposition === "obsolete" ? "obsolete" : "applied";
85
93
  break;
@@ -180,6 +188,14 @@ export class FileSchedulerStoreAdapter {
180
188
  return "applied";
181
189
  });
182
190
  }
191
+ runtimeTurnHasActiveNativeSubagents(input) {
192
+ const taskId = input.fence.taskId;
193
+ const run = this.store.getAgentRun(taskId, input.fence.runId);
194
+ if (run === null)
195
+ return false;
196
+ const projection = projectRuntimeTaskEvents(input.fence, run.createdAt, this.store.listEvents(taskId));
197
+ return Object.values(projection.operations).some(({ kind }) => kind === "subagent");
198
+ }
183
199
  validateCanonicalSessionObservation(input, now) {
184
200
  return this.store.transaction((store) => {
185
201
  const run = store.getAgentRun(input.fence.taskId, input.fence.runId);
@@ -1249,7 +1265,8 @@ export class FileSchedulerStoreAdapter {
1249
1265
  });
1250
1266
  }
1251
1267
  /**
1252
- * Fast hook path: durably records the native Turn boundary and a two-second
1268
+ * Fast hook path: validates the native Turn boundary before it is either
1269
+ * retained as an intermediate child wait or given a two-second workflow
1253
1270
  * closure deadline. It never performs tmux, workspace, or Controller I/O.
1254
1271
  */
1255
1272
  classifyRuntimeTurnCompleted(input) {
@@ -2808,8 +2825,13 @@ function runtimeObservationTelemetryEntry(input) {
2808
2825
  function compactedRuntimeObservationIds(events, incoming) {
2809
2826
  const existing = events.flatMap((event) => {
2810
2827
  const observation = runtimeObservationFromTaskEvent(event);
2828
+ const matches = incoming.kind.startsWith("operation.")
2829
+ ? observation !== null
2830
+ && runtimeObservationRunFenceMatches(observation.fence, incoming.fence)
2831
+ : observation !== null
2832
+ && runtimeObservationFenceMatches(observation.fence, incoming.fence);
2811
2833
  return observation !== null
2812
- && runtimeObservationFenceMatches(observation.fence, incoming.fence)
2834
+ && matches
2813
2835
  ? [{ event, observation }]
2814
2836
  : [];
2815
2837
  });
@@ -2838,7 +2860,8 @@ function compactedRuntimeObservationIds(events, incoming) {
2838
2860
  && observation.payload.sourceId === incoming.payload.sourceId;
2839
2861
  }
2840
2862
  if (["turn.completed", "turn.failed", "turn.cancelled"].includes(incoming.kind)) {
2841
- return observation.kind.startsWith("operation.")
2863
+ return (observation.kind.startsWith("operation.")
2864
+ && observation.payload.operation !== "subagent")
2842
2865
  || observation.kind === "turn.waiting"
2843
2866
  || observation.kind === "turn.completed"
2844
2867
  || observation.kind === "turn.failed"
@@ -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
  }
@@ -1,9 +1,11 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { lstat, readFile } from "node:fs/promises";
3
3
  import { createConnection } from "node:net";
4
- import { join } from "node:path";
5
- import { CONTROLLER_DISCOVERY_PATH, MAX_CONTROLLER_MESSAGE_BYTES, ControllerProtocolError, encodeControllerRequest, parseControllerDiscovery, parseControllerResponse } from "./protocol.js";
4
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
5
+ import { CONTROLLER_DISCOVERY_PATH, FILE_TASK_CONTROLLER_PROTOCOL_VERSION, MAX_CONTROLLER_MESSAGE_BYTES, ControllerProtocolError, encodeControllerRequest, parseControllerDiscovery, parseControllerResponse } from "./protocol.js";
6
6
  import { isControllerSocketPathForHome } from "./controllerEndpoint.js";
7
+ import { readHomeFilesystemId } from "./homeFilesystemIdentity.js";
8
+ import { findLiveControllerProcessForHome, inspectLiveControllerProcess } from "./controllerProcessIdentity.js";
7
9
  export class ControllerClientError extends Error {
8
10
  code;
9
11
  constructor(code, message) {
@@ -24,8 +26,11 @@ export async function readControllerDiscovery(home) {
24
26
  throw invalidDiscovery();
25
27
  }
26
28
  const value = JSON.parse(await readFile(discoveryPath, "utf8"));
27
- const socketPath = discoverySocketPath(home, value);
28
- return parseControllerDiscovery(value, socketPath);
29
+ const socketPath = discoverySocketPath(value);
30
+ return parseControllerDiscovery(value, {
31
+ homeFilesystemId: readHomeFilesystemId(home),
32
+ socketPath
33
+ });
29
34
  }
30
35
  catch (error) {
31
36
  if (error instanceof ControllerClientError)
@@ -36,13 +41,15 @@ export async function readControllerDiscovery(home) {
36
41
  throw invalidDiscovery();
37
42
  }
38
43
  }
39
- function discoverySocketPath(home, value) {
44
+ function discoverySocketPath(value) {
40
45
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
41
46
  throw invalidDiscovery();
42
47
  }
48
+ const homeId = Reflect.get(value, "homeId");
43
49
  const socketPath = Reflect.get(value, "socketPath");
44
- if (typeof socketPath !== "string"
45
- || !isControllerSocketPathForHome(home, socketPath)) {
50
+ if (typeof homeId !== "string"
51
+ || typeof socketPath !== "string"
52
+ || !isControllerSocketPathForHome(homeId, socketPath)) {
46
53
  throw invalidDiscovery();
47
54
  }
48
55
  return socketPath;
@@ -59,6 +66,10 @@ export async function callController(home, method, params = {}, options = {}) {
59
66
  requestLine = encodeControllerRequest({
60
67
  id,
61
68
  token: discovery.token,
69
+ protocolVersion: FILE_TASK_CONTROLLER_PROTOCOL_VERSION,
70
+ homeId: discovery.homeId,
71
+ homeFilesystemId: discovery.homeFilesystemId,
72
+ controllerInstanceId: discovery.controllerInstanceId,
62
73
  method,
63
74
  params
64
75
  });
@@ -71,6 +82,129 @@ export async function callController(home, method, params = {}, options = {}) {
71
82
  }
72
83
  return exchange(discovery.socketPath, requestLine, id, timeoutMs);
73
84
  }
85
+ /**
86
+ * One-generation transition used only by the documented `controller restart`
87
+ * upgrade path from the last released protocol (v3). Ordinary calls never
88
+ * accept or dispatch through this legacy shape.
89
+ */
90
+ export async function stopPreviousFileTaskController(home, timeoutMs) {
91
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) {
92
+ throw new TypeError("Controller timeout must be a positive integer.");
93
+ }
94
+ const discovery = await readPreviousControllerDiscovery(home);
95
+ const homeFilesystemId = readHomeFilesystemId(home);
96
+ if (inspectLiveControllerProcess(discovery.pid, homeFilesystemId, discovery.processStartIdentity) === undefined)
97
+ throw invalidDiscovery();
98
+ const id = randomUUID();
99
+ const requestLine = `${JSON.stringify({
100
+ id,
101
+ token: discovery.token,
102
+ method: "controller.stop",
103
+ params: { expectedPid: discovery.pid }
104
+ })}\n`;
105
+ const deadline = Date.now() + timeoutMs;
106
+ await exchange(discovery.socketPath, requestLine, id, timeoutMs);
107
+ while (inspectLiveControllerProcess(discovery.pid, homeFilesystemId, discovery.processStartIdentity) !== undefined) {
108
+ if (Date.now() >= deadline) {
109
+ throw new ControllerClientError("CONTROLLER_TIMEOUT", `Previous Controller did not stop within ${timeoutMs} ms.`);
110
+ }
111
+ await new Promise((resolve) => setTimeout(resolve, 20));
112
+ }
113
+ return Object.freeze({ pid: discovery.pid });
114
+ }
115
+ /**
116
+ * Explicit restart recovery for an exact Controller whose discovery record
117
+ * was lost. The signal is fenced by same UID, controller entrypoint, physical
118
+ * Home identity, PID, and process-start identity; an unprovable process is
119
+ * never touched.
120
+ */
121
+ export async function stopOrphanedFileTaskController(home, timeoutMs) {
122
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) {
123
+ throw new TypeError("Controller timeout must be a positive integer.");
124
+ }
125
+ const homeFilesystemId = readHomeFilesystemId(home);
126
+ const candidate = findLiveControllerProcessForHome(homeFilesystemId);
127
+ if (candidate === undefined)
128
+ return undefined;
129
+ if (inspectLiveControllerProcess(candidate.pid, homeFilesystemId, candidate.processStartIdentity) === undefined)
130
+ return undefined;
131
+ try {
132
+ process.kill(candidate.pid, "SIGTERM");
133
+ }
134
+ catch (error) {
135
+ if (isNodeError(error) && error.code === "ESRCH")
136
+ return undefined;
137
+ throw error;
138
+ }
139
+ const deadline = Date.now() + timeoutMs;
140
+ while (inspectLiveControllerProcess(candidate.pid, homeFilesystemId, candidate.processStartIdentity) !== undefined) {
141
+ if (Date.now() >= deadline) {
142
+ throw new ControllerClientError("CONTROLLER_TIMEOUT", `Orphaned Controller did not stop within ${timeoutMs} ms.`);
143
+ }
144
+ await new Promise((resolve) => setTimeout(resolve, 20));
145
+ }
146
+ return Object.freeze({ pid: candidate.pid });
147
+ }
148
+ async function readPreviousControllerDiscovery(home) {
149
+ const discoveryPath = join(home, CONTROLLER_DISCOVERY_PATH);
150
+ try {
151
+ const metadata = await lstat(discoveryPath);
152
+ const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
153
+ if (!metadata.isFile()
154
+ || (metadata.mode & 0o077) !== 0
155
+ || metadata.size > 4_096
156
+ || (uid !== undefined && metadata.uid !== uid))
157
+ throw invalidDiscovery();
158
+ const value = JSON.parse(await readFile(discoveryPath, "utf8"));
159
+ if (typeof value !== "object"
160
+ || value === null
161
+ || Array.isArray(value)
162
+ || !hasExactStringKeys(value, [
163
+ "pid",
164
+ "processStartIdentity",
165
+ "socketPath",
166
+ "token"
167
+ ]))
168
+ throw invalidDiscovery();
169
+ const record = value;
170
+ if (!Number.isSafeInteger(record.pid)
171
+ || record.pid < 1
172
+ || typeof record.processStartIdentity !== "string"
173
+ || !/^[0-9]{1,32}$/u.test(record.processStartIdentity)
174
+ || typeof record.socketPath !== "string"
175
+ || !isPreviousControllerSocketPath(record.socketPath)
176
+ || typeof record.token !== "string"
177
+ || !/^[a-f0-9]{64}$/u.test(record.token))
178
+ throw invalidDiscovery();
179
+ return Object.freeze({
180
+ pid: record.pid,
181
+ processStartIdentity: record.processStartIdentity,
182
+ socketPath: record.socketPath,
183
+ token: record.token
184
+ });
185
+ }
186
+ catch (error) {
187
+ if (error instanceof ControllerClientError)
188
+ throw error;
189
+ if (isNodeError(error) && error.code === "ENOENT") {
190
+ throw new ControllerClientError("CONTROLLER_NOT_RUNNING", "Controller is not running.");
191
+ }
192
+ throw invalidDiscovery();
193
+ }
194
+ }
195
+ function hasExactStringKeys(value, expected) {
196
+ const keys = Reflect.ownKeys(value);
197
+ return keys.length === expected.length
198
+ && keys.every((key) => typeof key === "string")
199
+ && expected.every((key) => Object.hasOwn(value, key));
200
+ }
201
+ function isPreviousControllerSocketPath(candidate) {
202
+ if (!isAbsolute(candidate) || resolve(candidate) !== candidate)
203
+ return false;
204
+ const uid = typeof process.getuid === "function" ? process.getuid() : 0;
205
+ return basename(dirname(candidate)) === `yui-${uid}`
206
+ && /^[a-f0-9]{24}\.sock$/u.test(basename(candidate));
207
+ }
74
208
  function exchange(socketPath, requestLine, expectedId, timeoutMs) {
75
209
  return new Promise((resolve, reject) => {
76
210
  const socket = createConnection(socketPath);
@@ -1,37 +1,25 @@
1
- import { createHash } from "node:crypto";
2
1
  import { tmpdir } from "node:os";
3
- import { basename, dirname, isAbsolute, join, resolve } from "node:path";
4
- const LINUX_UNIX_SOCKET_PATH_BUDGET = 100;
5
- export function controllerSocketPath(home) {
2
+ import { join } from "node:path";
3
+ import { validateHomeId } from "../repository/homeIdentity.js";
4
+ export function controllerSocketPath(homeId) {
5
+ const identity = validateHomeId(homeId);
6
6
  const uid = typeof process.getuid === "function" ? process.getuid() : 0;
7
- const socketName = `${controllerSocketIdentity(home)}.sock`;
8
- const isolatedPath = join(tmpdir(), `yui-${uid}`, socketName);
9
- if (process.platform !== "linux"
10
- || Buffer.byteLength(isolatedPath) < LINUX_UNIX_SOCKET_PATH_BUDGET) {
11
- return isolatedPath;
12
- }
13
- // Linux limits Unix-socket paths to a small fixed budget. Managed Task
14
- // runtimes intentionally use isolated TMPDIR roots, which may themselves
15
- // be nested deeply enough to exhaust that budget. Keep the Home/uid/name
16
- // fence while using the compact system temporary root only for this
17
- // exceptional path; clients follow the Home-owned discovery record.
18
- return join("/tmp", `yui-${uid}`, socketName);
7
+ const socketName = `${identity}.sock`;
8
+ // Linux Unix-socket paths have a small fixed budget. A stable per-uid system
9
+ // root is both short and independent of each caller's isolated TMPDIR.
10
+ const root = process.platform === "linux" ? "/tmp" : tmpdir();
11
+ return join(root, `yui-${uid}`, socketName);
19
12
  }
20
13
  /**
21
- * A protected Home discovery record owns the Controller endpoint. Clients may
22
- * run with an isolated TMPDIR, so validate the published Home/uid identity
23
- * without recomputing the Controller process's temporary root.
14
+ * A protected Home discovery record owns the Controller endpoint. Linux uses
15
+ * one fixed root, so every caller validates the exact Home/uid endpoint
16
+ * independently of its own TMPDIR.
24
17
  */
25
- export function isControllerSocketPathForHome(home, candidate) {
26
- if (!isAbsolute(candidate) || resolve(candidate) !== candidate)
18
+ export function isControllerSocketPathForHome(homeId, candidate) {
19
+ try {
20
+ return candidate === controllerSocketPath(validateHomeId(homeId));
21
+ }
22
+ catch {
27
23
  return false;
28
- const uid = typeof process.getuid === "function" ? process.getuid() : 0;
29
- return basename(dirname(candidate)) === `yui-${uid}`
30
- && basename(candidate) === `${controllerSocketIdentity(home)}.sock`;
31
- }
32
- function controllerSocketIdentity(home) {
33
- return createHash("sha256")
34
- .update(resolve(home))
35
- .digest("hex")
36
- .slice(0, 24);
24
+ }
37
25
  }
@@ -0,0 +1,56 @@
1
+ import { readFileSync, readdirSync } from "node:fs";
2
+ import { readLinuxProcessStartIdentity } from "../controller/domainIdentity.js";
3
+ import { readHomeFilesystemId, validateHomeFilesystemId } from "./homeFilesystemIdentity.js";
4
+ /** Exact live-process fence used only at Controller startup and v3 handoff. */
5
+ export function inspectLiveControllerProcess(pid, homeFilesystemId, expectedProcessStartIdentity) {
6
+ if (!Number.isSafeInteger(pid) || pid < 1 || pid === process.pid)
7
+ return undefined;
8
+ const expectedFilesystemId = validateHomeFilesystemId(homeFilesystemId);
9
+ try {
10
+ const uid = typeof process.getuid === "function" ? process.getuid() : 0;
11
+ const status = readFileSync(`/proc/${pid}/status`, "utf8");
12
+ const processUid = /^Uid:\s+([0-9]+)/mu.exec(status)?.[1];
13
+ if (processUid === undefined || Number(processUid) !== uid)
14
+ return undefined;
15
+ const args = readFileSync(`/proc/${pid}/cmdline`)
16
+ .toString("utf8")
17
+ .split("\0")
18
+ .filter((argument) => argument.length > 0);
19
+ if (!args.some((argument) => /(?:^|\/)controllerMain\.js$/u.test(argument))) {
20
+ return undefined;
21
+ }
22
+ const environment = readFileSync(`/proc/${pid}/environ`)
23
+ .toString("utf8")
24
+ .split("\0");
25
+ const yuiHome = environment
26
+ .find((entry) => entry.startsWith("YUI_HOME="))
27
+ ?.slice("YUI_HOME=".length);
28
+ if (yuiHome === undefined || readHomeFilesystemId(yuiHome) !== expectedFilesystemId) {
29
+ return undefined;
30
+ }
31
+ const processStartIdentity = readLinuxProcessStartIdentity(pid);
32
+ if (processStartIdentity === undefined
33
+ || (expectedProcessStartIdentity !== undefined
34
+ && processStartIdentity !== expectedProcessStartIdentity))
35
+ return undefined;
36
+ return Object.freeze({ pid, processStartIdentity });
37
+ }
38
+ catch {
39
+ return undefined;
40
+ }
41
+ }
42
+ /** Finds an older/current Controller whose discovery record was lost. */
43
+ export function findLiveControllerProcessForHome(homeFilesystemId) {
44
+ validateHomeFilesystemId(homeFilesystemId);
45
+ if (process.platform !== "linux")
46
+ return undefined;
47
+ const entries = readdirSync("/proc", { encoding: "utf8" });
48
+ for (const entry of entries) {
49
+ if (!/^[1-9][0-9]*$/u.test(entry))
50
+ continue;
51
+ const match = inspectLiveControllerProcess(Number(entry), homeFilesystemId);
52
+ if (match !== undefined)
53
+ return match;
54
+ }
55
+ return undefined;
56
+ }