@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.
@@ -6,6 +6,10 @@ import { basename, dirname, join, resolve } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { CONTROLLER_DISCOVERY_PATH, FILE_TASK_CONTROLLER_PROTOCOL_VERSION, MAX_CONTROLLER_MESSAGE_BYTES, ControllerProtocolError, controllerFailure, encodeControllerResponse, isEmptyParams, parseControllerRequest } from "./protocol.js";
8
8
  import { controllerSocketPath } from "./controllerEndpoint.js";
9
+ import { readHomeFilesystemId } from "./homeFilesystemIdentity.js";
10
+ import { findLiveControllerProcessForHome } from "./controllerProcessIdentity.js";
11
+ import { validateHomeId } from "../repository/homeIdentity.js";
12
+ import { readCompatibleHomeIdentity } from "../storage/compatibleTaskStore.js";
9
13
  import { YUI_VERSION, yuiVersionIdentity } from "../version.js";
10
14
  import { resolveStoreWorkerEnabledForHome } from "../storage/storeRpc.js";
11
15
  import { resolveTaskStoreBackendForHome } from "../storage/sqliteStore.js";
@@ -32,7 +36,14 @@ export async function startControllerServer(home, dispatcher, beforeDiscoveryRem
32
36
  }
33
37
  async function startControllerServerLocked(home, dispatcher, beforeDiscoveryRemoval, options = {}) {
34
38
  const discoveryPath = join(home, CONTROLLER_DISCOVERY_PATH);
35
- const socketPath = controllerSocketPath(home);
39
+ const homeId = validateHomeId(readCompatibleHomeIdentity(home).homeId);
40
+ const homeFilesystemId = readHomeFilesystemId(home);
41
+ await assertExistingDiscoveryReplaceable(discoveryPath);
42
+ if (findLiveControllerProcessForHome(homeFilesystemId) !== undefined) {
43
+ throw controllerAlreadyRunning();
44
+ }
45
+ const controllerInstanceId = randomBytes(16).toString("hex");
46
+ const socketPath = controllerSocketPath(homeId);
36
47
  const runtimeDirectory = dirname(discoveryPath);
37
48
  const socketDirectory = dirname(socketPath);
38
49
  await mkdir(runtimeDirectory, { recursive: true, mode: 0o700 });
@@ -66,7 +77,7 @@ async function startControllerServerLocked(home, dispatcher, beforeDiscoveryRemo
66
77
  // the live primary, so its startup identity is still truthful.
67
78
  let primaryIdentity = null;
68
79
  const netServer = createServer((socket) => {
69
- receiveRequest(socket, token, dispatcher, () => closeRunning(), options.status, telemetry, handover, home, () => primaryIdentity);
80
+ receiveRequest(socket, token, dispatcher, () => closeRunning(), options.status, telemetry, handover, home, homeId, homeFilesystemId, controllerInstanceId, () => primaryIdentity);
70
81
  });
71
82
  try {
72
83
  await listen(netServer, socketPath);
@@ -91,6 +102,11 @@ async function startControllerServerLocked(home, dispatcher, beforeDiscoveryRemo
91
102
  await chmod(socketPath, 0o600);
92
103
  const processStartIdentity = await readLinuxProcessStartIdentity(process.pid);
93
104
  const discovery = Object.freeze({
105
+ schemaVersion: 1,
106
+ protocolVersion: FILE_TASK_CONTROLLER_PROTOCOL_VERSION,
107
+ homeId,
108
+ homeFilesystemId,
109
+ controllerInstanceId,
94
110
  pid: process.pid,
95
111
  processStartIdentity,
96
112
  socketPath,
@@ -125,7 +141,7 @@ async function startControllerServerLocked(home, dispatcher, beforeDiscoveryRemo
125
141
  eventLoopDelay.stop();
126
142
  await beforeDiscoveryRemoval?.();
127
143
  await closeNetServer(netServer);
128
- await removeOwnedDiscovery(discoveryPath, token);
144
+ await removeOwnedDiscovery(discoveryPath, token, controllerInstanceId);
129
145
  if (options.domainIdentity !== undefined) {
130
146
  // Keep the exact target fence while detached Role panes survive a
131
147
  // Controller stop/restart. The owning test teardown or an expired
@@ -156,6 +172,54 @@ async function startControllerServerLocked(home, dispatcher, beforeDiscoveryRemo
156
172
  throw error;
157
173
  }
158
174
  }
175
+ /**
176
+ * Fence an older Controller whose socket name was derived by another protocol.
177
+ * A dead or PID-reused owner is stale and may be replaced; an unverifiable
178
+ * live owner fails closed so an endpoint migration can never create dual
179
+ * writers for one durable Home.
180
+ */
181
+ async function assertExistingDiscoveryReplaceable(discoveryPath) {
182
+ let value;
183
+ try {
184
+ value = JSON.parse(await readFile(discoveryPath, "utf8"));
185
+ }
186
+ catch (error) {
187
+ if (isNodeError(error) && error.code === "ENOENT")
188
+ return;
189
+ throw unsafeExistingDiscovery(discoveryPath, error);
190
+ }
191
+ if (typeof value !== "object"
192
+ || value === null
193
+ || Array.isArray(value)
194
+ || !("pid" in value)
195
+ || !Number.isSafeInteger(value.pid)
196
+ || value.pid < 1) {
197
+ throw unsafeExistingDiscovery(discoveryPath);
198
+ }
199
+ const pid = value.pid;
200
+ if (!isProcessAlive(pid))
201
+ return;
202
+ if (!("processStartIdentity" in value)
203
+ || typeof value.processStartIdentity !== "string"
204
+ || !/^[0-9]{1,32}$/u.test(value.processStartIdentity)) {
205
+ throw unsafeExistingDiscovery(discoveryPath);
206
+ }
207
+ let currentIdentity;
208
+ try {
209
+ currentIdentity = await readLinuxProcessStartIdentity(pid);
210
+ }
211
+ catch (error) {
212
+ if (!isProcessAlive(pid))
213
+ return;
214
+ throw unsafeExistingDiscovery(discoveryPath, error);
215
+ }
216
+ if (currentIdentity === value.processStartIdentity) {
217
+ throw controllerAlreadyRunning();
218
+ }
219
+ }
220
+ function unsafeExistingDiscovery(discoveryPath, cause) {
221
+ return new Error(`Cannot safely replace existing Controller discovery: ${discoveryPath}.`, cause === undefined ? undefined : { cause });
222
+ }
159
223
  async function writeDiscoveryAtomically(discoveryPath, discovery) {
160
224
  const temporaryPath = `${discoveryPath}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`;
161
225
  try {
@@ -171,7 +235,7 @@ async function writeDiscoveryAtomically(discoveryPath, discovery) {
171
235
  throw error;
172
236
  }
173
237
  }
174
- function receiveRequest(socket, token, dispatcher, stop, status, telemetry, handover, home, getPrimaryIdentity) {
238
+ function receiveRequest(socket, token, dispatcher, stop, status, telemetry, handover, home, homeId, homeFilesystemId, controllerInstanceId, getPrimaryIdentity) {
175
239
  let buffer = Buffer.alloc(0);
176
240
  let complete = false;
177
241
  const fail = (code, message, id = "invalid") => {
@@ -200,7 +264,7 @@ function receiveRequest(socket, token, dispatcher, stop, status, telemetry, hand
200
264
  return;
201
265
  }
202
266
  complete = true;
203
- void routeRequest(socket, buffer.subarray(0, newline).toString("utf8"), token, dispatcher, stop, status, telemetry, handover, home, getPrimaryIdentity);
267
+ void routeRequest(socket, buffer.subarray(0, newline).toString("utf8"), token, dispatcher, stop, status, telemetry, handover, home, homeId, homeFilesystemId, controllerInstanceId, getPrimaryIdentity);
204
268
  });
205
269
  socket.on("end", () => {
206
270
  if (!complete)
@@ -208,7 +272,7 @@ function receiveRequest(socket, token, dispatcher, stop, status, telemetry, hand
208
272
  });
209
273
  socket.on("error", () => undefined);
210
274
  }
211
- async function routeRequest(socket, line, token, dispatcher, stop, status, telemetry, handover, home, getPrimaryIdentity) {
275
+ async function routeRequest(socket, line, token, dispatcher, stop, status, telemetry, handover, home, homeId, homeFilesystemId, controllerInstanceId, getPrimaryIdentity) {
212
276
  let request;
213
277
  try {
214
278
  request = parseControllerRequest(line);
@@ -224,6 +288,16 @@ async function routeRequest(socket, line, token, dispatcher, stop, status, telem
224
288
  sendResponse(socket, controllerFailure(request.id, "UNAUTHORIZED", "Controller authentication failed."));
225
289
  return;
226
290
  }
291
+ if (request.protocolVersion !== FILE_TASK_CONTROLLER_PROTOCOL_VERSION) {
292
+ sendResponse(socket, controllerFailure(request.id, "CONTROLLER_PROTOCOL_MISMATCH", "Controller protocol version does not match the running Controller."));
293
+ return;
294
+ }
295
+ if (request.homeId !== homeId
296
+ || request.homeFilesystemId !== homeFilesystemId
297
+ || request.controllerInstanceId !== controllerInstanceId) {
298
+ sendResponse(socket, controllerFailure(request.id, "CONTROLLER_IDENTITY_MISMATCH", "Controller request identity does not match the running Controller."));
299
+ return;
300
+ }
227
301
  // Every authenticated request is observed once at the routing layer. The
228
302
  // observation completes when the response is handed to the socket, so a
229
303
  // later status snapshot sees prior built-in completions and the dispatcher
@@ -290,6 +364,9 @@ async function routeRequest(socket, line, token, dispatcher, stop, status, telem
290
364
  cliRealpath: controllerCliRealpath(),
291
365
  controllerRealpath: realpathSync(fileURLToPath(import.meta.url)),
292
366
  controllerProtocolVersion: FILE_TASK_CONTROLLER_PROTOCOL_VERSION,
367
+ homeId,
368
+ homeFilesystemId,
369
+ controllerInstanceId,
293
370
  storageBackend: resolveTaskStoreBackendForHome(home, process.env),
294
371
  workerEnabled: resolveStoreWorkerEnabledForHome(home, process.env),
295
372
  mode: "primary",
@@ -322,6 +399,9 @@ async function routeRequest(socket, line, token, dispatcher, stop, status, telem
322
399
  uptimeMs: Math.round(process.uptime() * 1000),
323
400
  rssBytes: process.memoryUsage().rss,
324
401
  protocolVersion: FILE_TASK_CONTROLLER_PROTOCOL_VERSION,
402
+ homeId,
403
+ homeFilesystemId,
404
+ controllerInstanceId,
325
405
  version: YUI_VERSION,
326
406
  storageLayoutVersion: yuiVersionIdentity().storageLayoutVersion,
327
407
  aggregateSchemaVersion: yuiVersionIdentity().aggregateSchemaVersion,
@@ -688,13 +768,15 @@ function closeNetServer(server) {
688
768
  server.close((error) => error === undefined ? resolve() : reject(error));
689
769
  });
690
770
  }
691
- async function removeOwnedDiscovery(path, token) {
771
+ async function removeOwnedDiscovery(path, token, controllerInstanceId) {
692
772
  try {
693
773
  const value = JSON.parse(await readFile(path, "utf8"));
694
774
  if (typeof value === "object"
695
775
  && value !== null
696
776
  && "token" in value
697
- && value.token === token) {
777
+ && value.token === token
778
+ && "controllerInstanceId" in value
779
+ && value.controllerInstanceId === controllerInstanceId) {
698
780
  await rm(path, { force: true });
699
781
  }
700
782
  }
@@ -0,0 +1,24 @@
1
+ import { statSync } from "node:fs";
2
+ const HOME_FILESYSTEM_ID_PATTERN = /^[0-9]{1,32}:[0-9]{1,32}$/u;
3
+ /**
4
+ * Runtime-only identity of the physical Home directory.
5
+ *
6
+ * Durable `homeId` names the logical Home. Device + inode distinguishes an
7
+ * accidental filesystem copy while allowing symlink aliases of the same
8
+ * directory to share one Controller. It is deliberately not persisted in the
9
+ * Home aggregate because replacing or copying the directory must change this
10
+ * runtime fence.
11
+ */
12
+ export function readHomeFilesystemId(home) {
13
+ const metadata = statSync(home, { bigint: true });
14
+ if (!metadata.isDirectory()) {
15
+ throw new Error("YUI_HOME is not a directory.");
16
+ }
17
+ return `${metadata.dev}:${metadata.ino}`;
18
+ }
19
+ export function validateHomeFilesystemId(value) {
20
+ if (typeof value !== "string" || !HOME_FILESYSTEM_ID_PATTERN.test(value)) {
21
+ throw new Error("Home filesystem identity is invalid.");
22
+ }
23
+ return value;
24
+ }
@@ -1,7 +1,9 @@
1
+ import { validateHomeId } from "../repository/homeIdentity.js";
2
+ import { validateHomeFilesystemId } from "./homeFilesystemIdentity.js";
1
3
  export const MAX_CONTROLLER_MESSAGE_BYTES = 1_048_576;
2
4
  export const CONTROLLER_DISCOVERY_PATH = "runtime/controller.json";
3
5
  /** Bump when a running Controller cannot safely share one YUI_HOME with this CLI. */
4
- export const FILE_TASK_CONTROLLER_PROTOCOL_VERSION = 3;
6
+ export const FILE_TASK_CONTROLLER_PROTOCOL_VERSION = 4;
5
7
  export class ControllerProtocolError extends Error {
6
8
  code;
7
9
  id;
@@ -18,12 +20,27 @@ export function parseControllerRequest(line) {
18
20
  }
19
21
  try {
20
22
  const value = JSON.parse(line);
21
- if (!isRecord(value) || !hasExactKeys(value, ["id", "token", "method", "params"])) {
23
+ if (!isRecord(value) || !hasExactKeys(value, [
24
+ "id",
25
+ "token",
26
+ "protocolVersion",
27
+ "homeId",
28
+ "homeFilesystemId",
29
+ "controllerInstanceId",
30
+ "method",
31
+ "params"
32
+ ])) {
22
33
  throw new Error("shape");
23
34
  }
24
35
  if (!isIdentifier(value.id)
25
36
  || typeof value.token !== "string"
26
37
  || !/^[a-f0-9]{64}$/u.test(value.token)
38
+ || !Number.isSafeInteger(value.protocolVersion)
39
+ || value.protocolVersion < 1
40
+ || !isHomeId(value.homeId)
41
+ || !isHomeFilesystemId(value.homeFilesystemId)
42
+ || typeof value.controllerInstanceId !== "string"
43
+ || !/^[a-f0-9]{32}$/u.test(value.controllerInstanceId)
27
44
  || typeof value.method !== "string"
28
45
  || value.method.length > 128
29
46
  || !/^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)*$/u.test(value.method)
@@ -33,6 +50,10 @@ export function parseControllerRequest(line) {
33
50
  return Object.freeze({
34
51
  id: value.id,
35
52
  token: value.token,
53
+ protocolVersion: value.protocolVersion,
54
+ homeId: value.homeId,
55
+ homeFilesystemId: value.homeFilesystemId,
56
+ controllerInstanceId: value.controllerInstanceId,
36
57
  method: value.method,
37
58
  params: value.params
38
59
  });
@@ -96,19 +117,41 @@ export function controllerFailure(id, code, message) {
96
117
  error: Object.freeze({ code, message })
97
118
  });
98
119
  }
99
- export function parseControllerDiscovery(value, expectedSocketPath) {
120
+ export function parseControllerDiscovery(value, expected) {
100
121
  if (!isRecord(value)
101
- || !hasExactKeys(value, ["pid", "processStartIdentity", "socketPath", "token"])
122
+ || !hasExactKeys(value, [
123
+ "schemaVersion",
124
+ "protocolVersion",
125
+ "homeId",
126
+ "homeFilesystemId",
127
+ "controllerInstanceId",
128
+ "pid",
129
+ "processStartIdentity",
130
+ "socketPath",
131
+ "token"
132
+ ])
133
+ || value.schemaVersion !== 1
134
+ || value.protocolVersion !== FILE_TASK_CONTROLLER_PROTOCOL_VERSION
135
+ || !isHomeId(value.homeId)
136
+ || value.homeFilesystemId !== expected.homeFilesystemId
137
+ || !isHomeFilesystemId(value.homeFilesystemId)
138
+ || typeof value.controllerInstanceId !== "string"
139
+ || !/^[a-f0-9]{32}$/u.test(value.controllerInstanceId)
102
140
  || !Number.isSafeInteger(value.pid)
103
141
  || value.pid < 1
104
142
  || typeof value.processStartIdentity !== "string"
105
143
  || !/^[0-9]{1,32}$/u.test(value.processStartIdentity)
106
- || value.socketPath !== expectedSocketPath
144
+ || value.socketPath !== expected.socketPath
107
145
  || typeof value.token !== "string"
108
146
  || !/^[a-f0-9]{64}$/u.test(value.token)) {
109
147
  throw new ControllerProtocolError("CONTROLLER_DISCOVERY_INVALID", "Controller discovery is invalid.");
110
148
  }
111
149
  return Object.freeze({
150
+ schemaVersion: 1,
151
+ protocolVersion: value.protocolVersion,
152
+ homeId: value.homeId,
153
+ homeFilesystemId: value.homeFilesystemId,
154
+ controllerInstanceId: value.controllerInstanceId,
112
155
  pid: value.pid,
113
156
  processStartIdentity: value.processStartIdentity,
114
157
  socketPath: value.socketPath,
@@ -131,6 +174,26 @@ function isIdentifier(value) {
131
174
  return typeof value === "string"
132
175
  && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u.test(value);
133
176
  }
177
+ function isHomeId(value) {
178
+ if (typeof value !== "string")
179
+ return false;
180
+ try {
181
+ validateHomeId(value);
182
+ return true;
183
+ }
184
+ catch {
185
+ return false;
186
+ }
187
+ }
188
+ function isHomeFilesystemId(value) {
189
+ try {
190
+ validateHomeFilesystemId(value);
191
+ return true;
192
+ }
193
+ catch {
194
+ return false;
195
+ }
196
+ }
134
197
  function isRecord(value) {
135
198
  return typeof value === "object" && value !== null && !Array.isArray(value);
136
199
  }
@@ -59,7 +59,7 @@ export class GitIntegrationService {
59
59
  environment;
60
60
  runtimeIsolation;
61
61
  #resourceRegistrarValue;
62
- constructor(home, store, git = new NodeGitWorkspace(), now = () => new Date(), environment = process.env, runtimeIsolation = defaultIntegrationRuntimeIsolation(home), jobPort) {
62
+ constructor(home, store, git = new NodeGitWorkspace(), now = () => new Date(), environment = process.env, runtimeIsolation = defaultIntegrationRuntimeIsolation(home, store.getHomeIdentity().homeId), jobPort) {
63
63
  this.store = store;
64
64
  this.git = git;
65
65
  this.now = now;
@@ -883,14 +883,14 @@ function isNodeCode(error, code) {
883
883
  && "code" in error
884
884
  && error.code === code;
885
885
  }
886
- function defaultIntegrationRuntimeIsolation(home) {
886
+ function defaultIntegrationRuntimeIsolation(home, homeId) {
887
887
  const controlHome = resolve(home);
888
888
  return new FileTaskRuntimeIsolation({
889
889
  runtimeRoot: integrationRuntimeRoot(),
890
890
  pathLayout: "compact",
891
891
  controlPlane: {
892
892
  yuiHome: controlHome,
893
- controllerSocketPath: controllerSocketPath(controlHome),
893
+ controllerSocketPath: controllerSocketPath(homeId),
894
894
  tmuxNamespace: yuiTmuxServerName(controlHome),
895
895
  globalInstallPaths: [process.execPath]
896
896
  }
@@ -1,6 +1,12 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  export const HOME_IDENTITY_PATTERN = /^home-[a-f0-9]{16}$/;
3
3
  const HOME_ENTROPY_BYTES = 16;
4
+ export function validateHomeId(homeId) {
5
+ if (typeof homeId !== "string" || !HOME_IDENTITY_PATTERN.test(homeId)) {
6
+ throw new Error("Home identity id is invalid.");
7
+ }
8
+ return homeId;
9
+ }
4
10
  export function generateHomeIdentity(now, source = () => randomBytes(HOME_ENTROPY_BYTES)) {
5
11
  const entropy = source().toString("hex");
6
12
  const homeId = `home-${randomBytes(8).toString("hex")}`;
@@ -15,9 +21,7 @@ export function validateHomeIdentity(identity) {
15
21
  if (identity.schemaVersion !== 1) {
16
22
  throw new Error("Home identity must use schemaVersion 1.");
17
23
  }
18
- if (typeof identity.homeId !== "string" || !HOME_IDENTITY_PATTERN.test(identity.homeId)) {
19
- throw new Error("Home identity id is invalid.");
20
- }
24
+ validateHomeId(identity.homeId);
21
25
  if (typeof identity.entropy !== "string" || !/^[a-f0-9]{32}$/.test(identity.entropy)) {
22
26
  throw new Error("Home identity entropy is invalid.");
23
27
  }
@@ -12,6 +12,9 @@ import { join, resolve } from "node:path";
12
12
  import { promisify } from "node:util";
13
13
  import { parseExactTaskRuntimeDescriptor } from "../runtime/exactControlPlane.js";
14
14
  import { readRuntimeIdentity, releasesDirectory, resolveActiveRelease } from "../release/runtimeRelease.js";
15
+ import { isControllerSocketPathForHome } from "../core/controllerEndpoint.js";
16
+ import { parseControllerDiscovery } from "../core/protocol.js";
17
+ import { readHomeFilesystemId } from "../core/homeFilesystemIdentity.js";
15
18
  const executeFile = promisify(execFile);
16
19
  /**
17
20
  * Collect every live reference for the given paths. The scan is fail-closed:
@@ -481,7 +484,11 @@ export function readControllerDiscovery(home) {
481
484
  }
482
485
  let record;
483
486
  try {
484
- record = JSON.parse(readFileSync(path, "utf8"));
487
+ const value = JSON.parse(readFileSync(path, "utf8"));
488
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
489
+ throw new Error("controller.json is not an object");
490
+ }
491
+ record = value;
485
492
  }
486
493
  catch (error) {
487
494
  return Object.freeze({
@@ -537,9 +544,35 @@ export function readControllerDiscovery(home) {
537
544
  }
538
545
  });
539
546
  }
547
+ let discovery;
548
+ try {
549
+ const homeId = record.homeId;
550
+ const socketPath = record.socketPath;
551
+ if (typeof homeId !== "string"
552
+ || typeof socketPath !== "string"
553
+ || !isControllerSocketPathForHome(homeId, socketPath)) {
554
+ throw new Error("Controller endpoint identity is invalid.");
555
+ }
556
+ discovery = parseControllerDiscovery(record, {
557
+ homeFilesystemId: readHomeFilesystemId(home),
558
+ socketPath
559
+ });
560
+ }
561
+ catch (error) {
562
+ return Object.freeze({
563
+ protects: false,
564
+ token: "",
565
+ protectedPaths: Object.freeze([]),
566
+ diagnostic: {
567
+ source: "controller",
568
+ severity: "error",
569
+ message: `controller.json identity is invalid: ${error instanceof Error ? error.message : "unknown error"}`
570
+ }
571
+ });
572
+ }
540
573
  return Object.freeze({
541
574
  protects: true,
542
- token: `controller:${record.pid}`,
575
+ token: `controller:${discovery.homeId}:${discovery.controllerInstanceId}:${record.pid}`,
543
576
  protectedPaths: Object.freeze([path])
544
577
  });
545
578
  }
@@ -118,6 +118,28 @@ export function runtimeObservationFenceMatches(expected, actual) {
118
118
  }
119
119
  return true;
120
120
  }
121
+ /**
122
+ * Matches observations that belong to one durable Run/session generation.
123
+ * A provider may advance its native Turn while background subagents from an
124
+ * earlier Turn are still active, so nativeTurnId is intentionally excluded.
125
+ */
126
+ export function runtimeObservationRunFenceMatches(expected, actual) {
127
+ for (const field of [
128
+ "taskId",
129
+ "roleName",
130
+ "runId",
131
+ "agentId",
132
+ "driverId",
133
+ "launchId",
134
+ "sessionGenerationId",
135
+ "nativeSessionId",
136
+ "receiptId"
137
+ ]) {
138
+ if (expected[field] !== actual[field])
139
+ return false;
140
+ }
141
+ return true;
142
+ }
121
143
  /** Compact TaskEvent payload used for durable state-boundary observations. */
122
144
  export function runtimeObservationTaskEventPayload(input) {
123
145
  const observation = createRuntimeObservation(input);
@@ -1,4 +1,4 @@
1
- import { createRuntimeObservation, runtimeObservationFenceMatches, runtimeObservationFromTaskEvent } from "./runtimeObservation.js";
1
+ import { createRuntimeObservation, runtimeObservationFromTaskEvent, runtimeObservationRunFenceMatches } from "./runtimeObservation.js";
2
2
  export function createRuntimeProjection(fence, createdAt) {
3
3
  const timestamp = requireTimestamp(createdAt);
4
4
  return Object.freeze({
@@ -20,7 +20,7 @@ export function createRuntimeProjection(fence, createdAt) {
20
20
  export function projectRuntimeTaskEvents(fence, createdAt, events) {
21
21
  const observations = events
22
22
  .map(runtimeObservationFromTaskEvent)
23
- .filter((event) => (event !== null && runtimeObservationFenceMatches(fence, event.fence)))
23
+ .filter((event) => (event !== null && runtimeObservationRunFenceMatches(fence, event.fence)))
24
24
  .sort((left, right) => (left.receivedAt.localeCompare(right.receivedAt)
25
25
  || (left.sequence ?? -1) - (right.sequence ?? -1)
26
26
  || (left.ordinal ?? -1) - (right.ordinal ?? -1)
@@ -29,7 +29,7 @@ export function projectRuntimeTaskEvents(fence, createdAt, events) {
29
29
  }
30
30
  export function projectRuntimeObservation(current, raw) {
31
31
  const event = createRuntimeObservation(raw);
32
- if (!runtimeObservationFenceMatches(current.fence, event.fence)) {
32
+ if (!runtimeObservationRunFenceMatches(current.fence, event.fence)) {
33
33
  throw new Error("Runtime observation fence does not match the projection.");
34
34
  }
35
35
  const at = event.receivedAt;
@@ -79,7 +79,10 @@ export function projectRuntimeObservation(current, raw) {
79
79
  turn: "completed",
80
80
  waitingReason: undefined,
81
81
  waitId: undefined,
82
- operations: Object.freeze({}),
82
+ // Tool operations belong to the completed native Turn. Provider-owned
83
+ // background subagents may legitimately outlive it and wake later
84
+ // native Turns inside the same durable Yui Run.
85
+ operations: activeSubagentOperations(current.operations),
83
86
  stateSince: at
84
87
  }), "provider", at);
85
88
  case "turn.failed":
@@ -261,6 +264,9 @@ function dominantOperation(operations) {
261
264
  return "model";
262
265
  return null;
263
266
  }
267
+ function activeSubagentOperations(operations) {
268
+ return Object.freeze(Object.fromEntries(Object.entries(operations).filter(([, operation]) => operation.kind === "subagent")));
269
+ }
264
270
  function terminalTurn(current, fallback = "cancelled") {
265
271
  return current === "completed" || current === "failed" || current === "cancelled"
266
272
  ? current
@@ -410,7 +410,8 @@ function leaderWakeupInput(taskId, runId, reasons, projectBindings) {
410
410
  : `Project Policy references: ${projectBindings.map((binding) => `${binding.directory} (${binding.projectId})`).join(", ")}. Read each with yui project show <project>, then yui project knowledge list <project> and yui project knowledge show <project> <knowledge>.`,
411
411
  "Use narrower Task message, WorkItem, decision, milestone, and input commands only when a specific record needs closer inspection.",
412
412
  `When the requested outcome is finished and there are no active Worker Runs or unresolved inputs, complete the Task with yui task complete ${taskId} --summary-file - and a quoted heredoc containing the final outcome and evidence.`,
413
- `Before ending this turn, if the Task was not completed and no InputRequest terminalized this Run, release the active fence with yui task run yield ${runId} --summary-file - and a quoted heredoc containing the current result or waiting state. In particular, yield before waiting for Worker results; do not end the native turn while this Run remains active. The yield command must be the final tool action: after it succeeds, stop immediately and do not inspect, poll, accept, or perform further work in the same native turn.`
413
+ "Provider-native subagents remain inside this Leader AgentRun. Yui observes their structured lifecycle and keeps the Run active across intermediate provider Turn boundaries while children remain active; let the provider deliver completion notifications and continue synthesis without polling or yielding merely for that native wait.",
414
+ `Before ending this turn, if the Task was not completed, no InputRequest terminalized this Run, and no provider-native child work remains active, release the active fence with yui task run yield ${runId} --summary-file - and a quoted heredoc containing the current result or waiting state. For managed Task Role or Reviewer results, checkpoint and yield before waiting so their durable mailbox can wake a later Leader Run. The yield command must be the final tool action: after it succeeds, stop immediately and do not inspect, poll, accept, or perform further work in the same native turn.`
414
415
  ];
415
416
  return lines.join("\n");
416
417
  }
@@ -3,8 +3,9 @@ import { join } from "node:path";
3
3
  import { StorageCompatibilityError, loadCompatibleSnapshot } from "./migration/index.js";
4
4
  import { createProductionStorageRegistry } from "./migration/productionRegistry.js";
5
5
  import { FileTaskStore, STORAGE_STATE_FILE, stateFileFingerprint, StorageRecordError, validateCurrentStorageStateSnapshot } from "./taskStore.js";
6
- import { SqliteTaskStore } from "./sqliteStore.js";
6
+ import { readSqliteHomeIdentity, SqliteTaskStore } from "./sqliteStore.js";
7
7
  import { COMMITTED_DATABASE_FILENAME } from "./upgrade/sqliteStateMigration.js";
8
+ import { validateHomeIdentity } from "../repository/homeIdentity.js";
8
9
  import { ensureStorageSchema, inspectStorageSchema, readStorageSchemaManifest, STORAGE_SCHEMA_FILE } from "./storageSchema.js";
9
10
  import { classifyHome } from "./upgrade/homeClassification.js";
10
11
  import { inspectSnapshotVersionState } from "./upgrade/homeMigrationTarget.js";
@@ -81,6 +82,40 @@ export function openCompatibleFileTaskStore(home, options = {}) {
81
82
  throw new StorageCompatibilityError(describeUnsupported(classification.classification));
82
83
  }
83
84
  }
85
+ /**
86
+ * Read the one authoritative durable Home identity without creating runtime
87
+ * state. SQLite is opened read-only; file-backed Homes read only the immutable
88
+ * identity field, so an aggregate that requires offline migration can still
89
+ * authenticate its already-running Controller before the migration stops it.
90
+ */
91
+ export function readCompatibleHomeIdentity(home, options = {}) {
92
+ const schema = inspectStorageSchema(home);
93
+ if (!options.forceStateSource
94
+ && (schema.status === "current" || schema.status === "unsupported")
95
+ && schema.currentLayoutVersion >= 7
96
+ && existsSync(join(home, COMMITTED_DATABASE_FILENAME))) {
97
+ return readSqliteHomeIdentity(home, COMMITTED_DATABASE_FILENAME);
98
+ }
99
+ if (!existsSync(join(home, STORAGE_STATE_FILE))) {
100
+ throw new StorageRecordError("Durable Home identity is missing.");
101
+ }
102
+ let parsed;
103
+ try {
104
+ parsed = JSON.parse(readFileSync(join(home, STORAGE_STATE_FILE), "utf8"));
105
+ }
106
+ catch {
107
+ throw new StorageRecordError("Durable Home identity state is invalid JSON.");
108
+ }
109
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
110
+ throw new StorageRecordError("Durable Home identity state is invalid.");
111
+ }
112
+ try {
113
+ return validateHomeIdentity(parsed.homeIdentity);
114
+ }
115
+ catch {
116
+ throw new StorageRecordError("Durable Home identity is invalid.");
117
+ }
118
+ }
84
119
  /**
85
120
  * The one-snapshot open for a Home whose manifest is already current. A single
86
121
  * fingerprint-fenced read of `state.json` supplies:
@@ -43,7 +43,7 @@ import { mailboxTargetKey } from "../coordination/workMailbox.js";
43
43
  import { validatePendingTurnCompletion } from "../executor/turnCompletion.js";
44
44
  import { compareRuntimeSessionCandidates, projectRuntimeSessionCandidate } from "../runtime/runtimeSessionCandidate.js";
45
45
  import { validateReviewFinding } from "../review/reviewFinding.js";
46
- import { generateHomeIdentity } from "../repository/homeIdentity.js";
46
+ import { generateHomeIdentity, validateHomeIdentity } from "../repository/homeIdentity.js";
47
47
  import { validateIntegrationQueueEntry } from "../integration/integrationQueueEntry.js";
48
48
  import { validDurableJobTransition, validateDurableJob } from "../job/durableJob.js";
49
49
  import { TASK_RECORD_ID_PREFIXES } from "../task/taskRecordReference.js";
@@ -53,6 +53,30 @@ import { CURRENT_CONFIG_SCHEMA_VERSION, CURRENT_PENDING_WAKEUP_SCHEMA_VERSION, C
53
53
  import { gateArtifactKey, validateGateArtifact } from "../verification/gateArtifact.js";
54
54
  import { migrateSqliteSchema, SQLITE_AGGREGATE_VERSION, SQLITE_LAYOUT_VERSION, TELEMETRY_KEEP_PER_GENERATION, TELEMETRY_RUN_CAP } from "./sqliteSchema.js";
55
55
  import { inspectStorageSchema } from "./storageSchema.js";
56
+ /** Read the immutable Home identity without opening a writable Store connection. */
57
+ export function readSqliteHomeIdentity(rootDir, databaseFilename = "yui.db") {
58
+ const database = new Database(join(rootDir, databaseFilename), {
59
+ readonly: true,
60
+ fileMustExist: true
61
+ });
62
+ try {
63
+ const row = database.prepare("SELECT home_identity FROM home_meta WHERE id = 1").get();
64
+ if (typeof row?.home_identity !== "string") {
65
+ throw new StorageRecordError("SQLite Home identity is missing.");
66
+ }
67
+ let parsed;
68
+ try {
69
+ parsed = JSON.parse(row.home_identity);
70
+ }
71
+ catch {
72
+ throw new StorageRecordError("SQLite Home identity is invalid JSON.");
73
+ }
74
+ return validateHomeIdentity(parsed);
75
+ }
76
+ finally {
77
+ database.close();
78
+ }
79
+ }
56
80
  const DEFAULT_CONFIG = { schemaVersion: CURRENT_CONFIG_SCHEMA_VERSION };
57
81
  function numericCompare(left, right) {
58
82
  return left.localeCompare(right, undefined, { numeric: true });