@zq-silk/yui 0.6.0 → 0.6.2

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.
Files changed (150) hide show
  1. package/README.md +5 -5
  2. package/dist/agent/managedRuntimeEnvironment.js +2 -1
  3. package/dist/cli/commandCatalog.js +251 -13
  4. package/dist/cli/updateOrchestrator.js +8 -0
  5. package/dist/cli/updatePorts.js +76 -22
  6. package/dist/cli.js +264 -20
  7. package/dist/commands/configCommands.js +83 -9
  8. package/dist/commands/controllerCommands.js +103 -0
  9. package/dist/commands/deliveryGuardPreflight.js +30 -0
  10. package/dist/commands/durableJobCommands.js +231 -0
  11. package/dist/commands/executionAuditCommands.js +193 -0
  12. package/dist/commands/grantCommands.js +374 -0
  13. package/dist/commands/projectCommands.js +119 -81
  14. package/dist/commands/releaseCommands.js +444 -0
  15. package/dist/commands/resourcesCommands.js +274 -0
  16. package/dist/commands/sessionCommands.js +104 -0
  17. package/dist/commands/taskActor.js +117 -0
  18. package/dist/commands/taskChangeSetCommands.js +60 -0
  19. package/dist/commands/taskCommands.js +618 -202
  20. package/dist/commands/taskCompletionGate.js +78 -1
  21. package/dist/commands/taskContextCommand.js +33 -6
  22. package/dist/commands/taskInputCommands.js +1 -1
  23. package/dist/commands/taskIntegrationCommands.js +136 -33
  24. package/dist/commands/taskIntegrationQueueCommands.js +228 -0
  25. package/dist/commands/taskNextActionCommand.js +100 -0
  26. package/dist/commands/taskOverlapCommands.js +120 -0
  27. package/dist/commands/taskOverviewCommand.js +36 -8
  28. package/dist/commands/telemetryCommands.js +330 -0
  29. package/dist/commands/workflowCommands.js +415 -0
  30. package/dist/config/yuiConfig.js +62 -0
  31. package/dist/controller/clientRuntime.js +42 -1
  32. package/dist/controller/controller.js +402 -56
  33. package/dist/controller/controllerMain.js +25 -2
  34. package/dist/controller/domainIdentity.js +16 -8
  35. package/dist/controller/fileSchedulerStoreAdapter.js +423 -31
  36. package/dist/controller/handoverCandidate.js +168 -0
  37. package/dist/controller/jobClient.js +102 -0
  38. package/dist/controller/jobControl.js +613 -0
  39. package/dist/controller/jobSupervisor.js +498 -0
  40. package/dist/controller/providerHookRunFence.js +34 -5
  41. package/dist/controller/resourceCleanupLinux.js +18 -9
  42. package/dist/controller/resourceInventoryLinux.js +90 -39
  43. package/dist/controller/runtime.js +165 -15
  44. package/dist/controller/runtimeEventInbox.js +234 -57
  45. package/dist/controller/runtimeEventProcessor.js +297 -58
  46. package/dist/controller/sessionOwnerReconciliation.js +321 -0
  47. package/dist/core/controllerServer.js +416 -27
  48. package/dist/core/controllerTelemetry.js +167 -0
  49. package/dist/doctor/doctor.js +113 -16
  50. package/dist/domain/validation.js +9 -0
  51. package/dist/execution/executionGroup.js +40 -3
  52. package/dist/executor/agentExecutor.js +6 -3
  53. package/dist/executor/effectiveLaunch.js +52 -0
  54. package/dist/executor/executorRegistry.js +50 -0
  55. package/dist/executor/fileRoleLaunchPlanner.js +61 -6
  56. package/dist/grant/capabilityGrant.js +282 -0
  57. package/dist/integration/changeSet.js +16 -3
  58. package/dist/integration/changeSetManifest.js +46 -0
  59. package/dist/integration/gitIntegrationService.js +528 -147
  60. package/dist/integration/integrationAttempt.js +54 -5
  61. package/dist/integration/integrationQueueEntry.js +221 -0
  62. package/dist/integration/integrationQueueService.js +955 -0
  63. package/dist/integration/manifestTags.js +99 -0
  64. package/dist/integration/overlapDiagnostics.js +211 -0
  65. package/dist/job/durableJob.js +449 -0
  66. package/dist/job/jobRunner.js +350 -0
  67. package/dist/lifecycle/exactRunTerminalization.js +24 -2
  68. package/dist/lifecycle/providerErrorClass.js +126 -0
  69. package/dist/message/message.js +16 -3
  70. package/dist/observability/executionAudit.js +545 -0
  71. package/dist/observability/faultClassification.js +160 -0
  72. package/dist/observability/runtimeIdentity.js +367 -0
  73. package/dist/release/fakeReleasePorts.js +55 -0
  74. package/dist/release/releaseHandover.js +475 -0
  75. package/dist/release/releaseIdempotencyStore.js +165 -0
  76. package/dist/release/releaseWorkflow.js +459 -0
  77. package/dist/release/releaseWorkflowEngine.js +688 -0
  78. package/dist/release/releaseWorkflowPorts.js +1720 -0
  79. package/dist/release/runtimeRelease.js +495 -0
  80. package/dist/release/workflowFileLock.js +218 -0
  81. package/dist/repository/gitWorkspace.js +177 -1
  82. package/dist/repository/projectMaintenanceLock.js +315 -0
  83. package/dist/repository/taskWorkspaceCoordinator.js +87 -17
  84. package/dist/repository/taskWorkspacePreparer.js +1091 -517
  85. package/dist/resources/autoResourceGc.js +116 -0
  86. package/dist/resources/liveReferences.js +574 -0
  87. package/dist/resources/resourceDiscovery.js +477 -0
  88. package/dist/resources/resourceGc.js +645 -0
  89. package/dist/resources/resourceRegistrar.js +256 -0
  90. package/dist/resources/resourceRegistry.js +150 -0
  91. package/dist/resources/resourceRegistryStore.js +41 -0
  92. package/dist/resources/resourceTypes.js +42 -0
  93. package/dist/resources/sqliteResourceRegistry.js +111 -0
  94. package/dist/review/reviewConfig.js +10 -0
  95. package/dist/review/reviewFinding.js +240 -0
  96. package/dist/review/reviewFindingLedger.js +545 -0
  97. package/dist/review/reviewOutcomeClassifier.js +61 -0
  98. package/dist/review/reviewRound.js +56 -4
  99. package/dist/run/agentRun.js +80 -4
  100. package/dist/run/providerRetry.js +84 -0
  101. package/dist/run/providerRetryConfig.js +63 -0
  102. package/dist/run/yieldReceipt.js +65 -0
  103. package/dist/runtime/exactControlPlane.js +79 -2
  104. package/dist/runtime/index.js +4 -0
  105. package/dist/runtime/sessionOwnerIdentity.js +269 -0
  106. package/dist/runtime/sessionOwnerRegistry.js +132 -0
  107. package/dist/runtime/sessionReconciliation.js +93 -0
  108. package/dist/runtime/sessionTerminationGuard.js +211 -0
  109. package/dist/runtime/taskRuntimeIsolation.js +13 -0
  110. package/dist/runtime/tmuxAdapters.js +34 -1
  111. package/dist/scheduler/actionability.js +155 -0
  112. package/dist/scheduler/activeRoleRunDelivery.js +14 -5
  113. package/dist/scheduler/activeTaskProgress.js +60 -0
  114. package/dist/scheduler/leaderWakeupProcessor.js +22 -11
  115. package/dist/scheduler/roleRunStall.js +135 -29
  116. package/dist/scheduler/taskExecutionProjection.js +11 -0
  117. package/dist/storage/compatibleTaskStore.js +112 -5
  118. package/dist/storage/migration/productionRegistry.js +736 -1
  119. package/dist/storage/sqliteSchema.js +264 -3
  120. package/dist/storage/sqliteStore.js +487 -13
  121. package/dist/storage/storeRpc.js +21 -0
  122. package/dist/storage/taskStore.js +974 -21
  123. package/dist/storage/upgrade/homeClassification.js +120 -2
  124. package/dist/storage/upgrade/migrationReceipt.js +67 -0
  125. package/dist/storage/upgrade/pseudoLayoutRepair.js +241 -0
  126. package/dist/storage/upgrade/recordVersions.js +10 -1
  127. package/dist/storage/upgrade/sqliteMigrationTarget.js +58 -6
  128. package/dist/storage/upgrade/sqliteRecordMigrationTarget.js +290 -0
  129. package/dist/storage/upgrade/sqliteStateMigration.js +258 -2
  130. package/dist/storage/upgrade/upgradeOrchestrator.js +482 -16
  131. package/dist/task/deliveryGuard.js +226 -0
  132. package/dist/task/nextAction.js +738 -0
  133. package/dist/task/repairWave.js +137 -0
  134. package/dist/task/taskRecordReference.js +6 -1
  135. package/dist/telemetry/sqliteTelemetryStore.js +387 -0
  136. package/dist/telemetry/telemetryCompaction.js +251 -0
  137. package/dist/telemetry/telemetryConfig.js +64 -0
  138. package/dist/telemetry/telemetryRouter.js +32 -0
  139. package/dist/telemetry/telemetryStore.js +19 -0
  140. package/dist/telemetry/telemetryWiring.js +33 -0
  141. package/dist/tmux/tmuxManager.js +20 -1
  142. package/dist/tmux/tmuxSocketEndpoint.js +20 -0
  143. package/dist/verification/gateArtifact.js +216 -0
  144. package/dist/verification/gateArtifactStore.js +87 -0
  145. package/dist/verification/verificationGateService.js +414 -0
  146. package/dist/verification/verificationPlan.js +308 -0
  147. package/dist/workspace/gitChangeSetCapture.js +12 -2
  148. package/dist/workspace/workItemChangeSetManager.js +60 -3
  149. package/package.json +1 -1
  150. package/skills/yui-leader/SKILL.md +8 -0
@@ -1,11 +1,26 @@
1
1
  import { randomBytes, timingSafeEqual } from "node:crypto";
2
2
  import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
+ import { readFileSync, realpathSync } from "node:fs";
3
4
  import { createConnection, createServer } from "node:net";
4
5
  import { basename, dirname, join, resolve } from "node:path";
6
+ import { fileURLToPath } from "node:url";
5
7
  import { CONTROLLER_DISCOVERY_PATH, FILE_TASK_CONTROLLER_PROTOCOL_VERSION, MAX_CONTROLLER_MESSAGE_BYTES, ControllerProtocolError, controllerFailure, encodeControllerResponse, isEmptyParams, parseControllerRequest } from "./protocol.js";
6
8
  import { controllerSocketPath } from "./controllerEndpoint.js";
7
9
  import { YUI_VERSION, yuiVersionIdentity } from "../version.js";
8
- import { removeEphemeralDomainIdentity, readEphemeralDomainIdentity, writeEphemeralDomainIdentity } from "../controller/domainIdentity.js";
10
+ import { resolveStoreWorkerEnabledForHome } from "../storage/storeRpc.js";
11
+ import { resolveTaskStoreBackendForHome } from "../storage/sqliteStore.js";
12
+ import { CommandExecutionError } from "../tmux/commandExecutor.js";
13
+ import { detectRunningRelease, readActiveReleasePointer, readHandoverFence, verifyReleaseIntegrity, writeHandoverFence, writeRuntimeIdentity } from "../release/runtimeRelease.js";
14
+ import { isBuiltinControllerMethod, ControllerCommandObserver, ControllerEventLoopDelay } from "./controllerTelemetry.js";
15
+ import { parseLinuxProcessStartIdentity, removeEphemeralDomainIdentity, readEphemeralDomainIdentity, writeEphemeralDomainIdentity } from "../controller/domainIdentity.js";
16
+ /** Methods that stay available while a handover fence is active. */
17
+ const HANDOVER_ALLOWED_METHODS = new Set([
18
+ "controller.identity",
19
+ "controller.status",
20
+ "controller.handover-state",
21
+ "controller.commit-handover",
22
+ "controller.rollback-handover"
23
+ ]);
9
24
  export async function startControllerServer(home, dispatcher, beforeDiscoveryRemoval, options = {}) {
10
25
  const releaseLifecycleLock = await acquireHomeLifecycleLock(home);
11
26
  try {
@@ -26,8 +41,32 @@ async function startControllerServerLocked(home, dispatcher, beforeDiscoveryRemo
26
41
  await chmod(socketDirectory, 0o700);
27
42
  const token = randomBytes(32).toString("hex");
28
43
  let closeRunning = async () => undefined;
44
+ const commandObserver = new ControllerCommandObserver();
45
+ const eventLoopDelay = new ControllerEventLoopDelay();
46
+ const telemetry = Object.freeze({
47
+ commandObserver,
48
+ eventLoopDelay
49
+ });
50
+ const handover = { fence: null };
51
+ const release = options.release === undefined
52
+ ? detectRunningRelease(fileURLToPath(import.meta.url))
53
+ : options.release;
54
+ // Issue 01: the Home decides the backend. The receipt reports the truthful
55
+ // Home-decided backend (SQLite for a layout-7 Home with yui.db, file store
56
+ // for older layouts) instead of deriving it from YUI_STORE_BACKEND, which is
57
+ // reserved for tests and explicit recovery commands.
58
+ const storageBackend = options.storageBackend
59
+ ?? resolveTaskStoreBackendForHome(home, process.env);
60
+ const workerEnabled = options.workerEnabled
61
+ ?? resolveStoreWorkerEnabledForHome(home, process.env);
62
+ // The primary identity receipt is captured at startup and re-published on a
63
+ // handover rollback: the candidate overwrites runtime-identity.json while it
64
+ // runs, and after the rollback kills the candidate the file must not keep
65
+ // pointing at the dead candidate's release/PID. The old Controller is still
66
+ // the live primary, so its startup identity is still truthful.
67
+ let primaryIdentity = null;
29
68
  const netServer = createServer((socket) => {
30
- receiveRequest(socket, token, dispatcher, () => closeRunning());
69
+ receiveRequest(socket, token, dispatcher, () => closeRunning(), options.status, telemetry, handover, home, () => primaryIdentity);
31
70
  });
32
71
  try {
33
72
  await listen(netServer, socketPath);
@@ -47,6 +86,7 @@ async function startControllerServerLocked(home, dispatcher, beforeDiscoveryRemo
47
86
  throw retryError;
48
87
  }
49
88
  }
89
+ eventLoopDelay.start();
50
90
  try {
51
91
  await chmod(socketPath, 0o600);
52
92
  const processStartIdentity = await readLinuxProcessStartIdentity(process.pid);
@@ -60,6 +100,19 @@ async function startControllerServerLocked(home, dispatcher, beforeDiscoveryRemo
60
100
  writeEphemeralDomainIdentity(home, options.domainIdentity);
61
101
  }
62
102
  await writeDiscoveryAtomically(discoveryPath, discovery);
103
+ // Issue 02: every Controller start publishes its provenance. The receipt
104
+ // is the stable, file-based identity that survives a Controller stop and
105
+ // backs the read-only `controller identity --json` command.
106
+ primaryIdentity = buildRuntimeIdentityReceipt({
107
+ home,
108
+ release,
109
+ storageBackend,
110
+ workerEnabled,
111
+ processStartIdentity,
112
+ mode: "primary",
113
+ dualOwner: false
114
+ });
115
+ writeRuntimeIdentity(home, primaryIdentity);
63
116
  let resolveClosed = () => undefined;
64
117
  const closed = new Promise((resolve) => {
65
118
  resolveClosed = resolve;
@@ -69,6 +122,7 @@ async function startControllerServerLocked(home, dispatcher, beforeDiscoveryRemo
69
122
  if (closePromise !== undefined)
70
123
  return closePromise;
71
124
  closePromise = (async () => {
125
+ eventLoopDelay.stop();
72
126
  await beforeDiscoveryRemoval?.();
73
127
  await closeNetServer(netServer);
74
128
  await removeOwnedDiscovery(discoveryPath, token);
@@ -88,9 +142,16 @@ async function startControllerServerLocked(home, dispatcher, beforeDiscoveryRemo
88
142
  })();
89
143
  return closePromise;
90
144
  };
91
- return Object.freeze({ discovery, closed, close: closeRunning });
145
+ return Object.freeze({
146
+ discovery,
147
+ closed,
148
+ close: closeRunning,
149
+ commandObserver,
150
+ eventLoopDelay
151
+ });
92
152
  }
93
153
  catch (error) {
154
+ eventLoopDelay.stop();
94
155
  await closeNetServer(netServer);
95
156
  throw error;
96
157
  }
@@ -110,7 +171,7 @@ async function writeDiscoveryAtomically(discoveryPath, discovery) {
110
171
  throw error;
111
172
  }
112
173
  }
113
- function receiveRequest(socket, token, dispatcher, stop) {
174
+ function receiveRequest(socket, token, dispatcher, stop, status, telemetry, handover, home, getPrimaryIdentity) {
114
175
  let buffer = Buffer.alloc(0);
115
176
  let complete = false;
116
177
  const fail = (code, message, id = "invalid") => {
@@ -139,7 +200,7 @@ function receiveRequest(socket, token, dispatcher, stop) {
139
200
  return;
140
201
  }
141
202
  complete = true;
142
- void routeRequest(socket, buffer.subarray(0, newline).toString("utf8"), token, dispatcher, stop);
203
+ void routeRequest(socket, buffer.subarray(0, newline).toString("utf8"), token, dispatcher, stop, status, telemetry, handover, home, getPrimaryIdentity);
143
204
  });
144
205
  socket.on("end", () => {
145
206
  if (!complete)
@@ -147,7 +208,7 @@ function receiveRequest(socket, token, dispatcher, stop) {
147
208
  });
148
209
  socket.on("error", () => undefined);
149
210
  }
150
- async function routeRequest(socket, line, token, dispatcher, stop) {
211
+ async function routeRequest(socket, line, token, dispatcher, stop, status, telemetry, handover, home, getPrimaryIdentity) {
151
212
  let request;
152
213
  try {
153
214
  request = parseControllerRequest(line);
@@ -163,40 +224,108 @@ async function routeRequest(socket, line, token, dispatcher, stop) {
163
224
  sendResponse(socket, controllerFailure(request.id, "UNAUTHORIZED", "Controller authentication failed."));
164
225
  return;
165
226
  }
227
+ // Every authenticated request is observed once at the routing layer. The
228
+ // observation completes when the response is handed to the socket, so a
229
+ // later status snapshot sees prior built-in completions and the dispatcher
230
+ // service-time metric stays a distinct observation.
231
+ const observation = telemetry.commandObserver.start();
232
+ const send = (response, onFlushed) => {
233
+ observation.complete(isBuiltinControllerMethod(request.method) ? "builtin" : "dispatched", response.ok ? "success" : "failure");
234
+ sendResponse(socket, response, onFlushed);
235
+ };
236
+ // Issue 02: a fenced Controller is mid-handover. It stops accepting new
237
+ // mutations and only answers the handover control methods plus read-only
238
+ // identity/status queries until the candidate promotes or the handover
239
+ // rolls back.
240
+ if (handover.fence !== null && !HANDOVER_ALLOWED_METHODS.has(request.method)) {
241
+ send(controllerFailure(request.id, "CONTROLLER_HANDOVER_FENCED", "Controller is fenced for a release handover; retry after the handover completes."));
242
+ return;
243
+ }
166
244
  if (request.method === "controller.identity") {
167
245
  if (!isEmptyParams(request.params)) {
168
- sendResponse(socket, controllerFailure(request.id, "INVALID_PARAMS", "Controller params are invalid."));
246
+ send(controllerFailure(request.id, "INVALID_PARAMS", "Controller params are invalid."));
169
247
  return;
170
248
  }
171
249
  // This is an authenticated, private lifecycle response. Unlike the public
172
250
  // resource inventory, it retains the exact executable/argv that the socket
173
251
  // owner was launched with so an update can restore that same Controller.
174
- sendResponse(socket, {
252
+ // Issue 02 extends it with the release provenance the handover and the
253
+ // exact control-plane gate consume.
254
+ // The pid + process start identity let `release activate` fence this exact
255
+ // owner as the old Controller during a handover; without them the
256
+ // activator cannot distinguish a live owner from an empty Home and would
257
+ // bypass the handover sequence.
258
+ const release = detectRunningRelease(fileURLToPath(import.meta.url));
259
+ // Verify the running release's integrity so a drifted Controller can be
260
+ // refused by a handover activator. The Controller stays alive; the flag
261
+ // lets the activator fail closed without killing the serving process.
262
+ let releaseDrifted = false;
263
+ if (release !== null) {
264
+ try {
265
+ verifyReleaseIntegrity(release.releaseDir);
266
+ }
267
+ catch {
268
+ releaseDrifted = true;
269
+ }
270
+ }
271
+ let processStartIdentity;
272
+ try {
273
+ processStartIdentity = readLinuxProcessStartIdentitySync(process.pid);
274
+ }
275
+ catch {
276
+ // Non-Linux or an unreadable /proc: the owner cannot be fenced, so the
277
+ // handover path treats it as absent. The rest of the identity stays
278
+ // available for read-only consumers.
279
+ }
280
+ send({
175
281
  id: request.id,
176
282
  ok: true,
177
283
  result: {
178
284
  executablePath: process.execPath,
179
285
  args: process.argv.slice(1),
180
- version: YUI_VERSION
286
+ version: YUI_VERSION,
287
+ buildId: release?.manifest.buildId ?? "dev",
288
+ packageDigest: release?.manifest.packageDigest ?? null,
289
+ sourceCommit: release?.manifest.sourceCommit ?? null,
290
+ cliRealpath: controllerCliRealpath(),
291
+ controllerRealpath: realpathSync(fileURLToPath(import.meta.url)),
292
+ controllerProtocolVersion: FILE_TASK_CONTROLLER_PROTOCOL_VERSION,
293
+ storageBackend: resolveTaskStoreBackendForHome(home, process.env),
294
+ workerEnabled: resolveStoreWorkerEnabledForHome(home, process.env),
295
+ mode: "primary",
296
+ dualOwner: false,
297
+ ...(releaseDrifted ? { releaseDrifted: true } : {}),
298
+ // A corrupted pointer file must not kill the serving Controller with
299
+ // an unhandled rejection; surface a degraded identity so the caller
300
+ // can diagnose, and let `release activate` fail on its own read.
301
+ activeRelease: readActiveReleasePointerSafe(home),
302
+ pid: process.pid,
303
+ ...(processStartIdentity === undefined
304
+ ? {}
305
+ : { processStartIdentity })
181
306
  }
182
307
  });
183
308
  return;
184
309
  }
185
310
  if (request.method === "controller.status") {
186
311
  if (!isEmptyParams(request.params)) {
187
- sendResponse(socket, controllerFailure(request.id, "INVALID_PARAMS", "Controller params are invalid."));
312
+ send(controllerFailure(request.id, "INVALID_PARAMS", "Controller params are invalid."));
188
313
  return;
189
314
  }
190
- sendResponse(socket, {
315
+ send({
191
316
  id: request.id,
192
317
  ok: true,
193
318
  result: {
194
319
  pid: process.pid,
195
320
  running: true,
321
+ // Issue 11: process identity facts for status/audit. Read-only.
322
+ uptimeMs: Math.round(process.uptime() * 1000),
323
+ rssBytes: process.memoryUsage().rss,
196
324
  protocolVersion: FILE_TASK_CONTROLLER_PROTOCOL_VERSION,
197
325
  version: YUI_VERSION,
198
326
  storageLayoutVersion: yuiVersionIdentity().storageLayoutVersion,
199
- aggregateSchemaVersion: yuiVersionIdentity().aggregateSchemaVersion
327
+ aggregateSchemaVersion: yuiVersionIdentity().aggregateSchemaVersion,
328
+ ...(status === undefined ? {} : { runtime: status(telemetry) })
200
329
  }
201
330
  });
202
331
  return;
@@ -204,14 +333,14 @@ async function routeRequest(socket, line, token, dispatcher, stop) {
204
333
  if (request.method === "controller.stop") {
205
334
  const expectedPid = expectedControllerStopPid(request.params);
206
335
  if (expectedPid === null) {
207
- sendResponse(socket, controllerFailure(request.id, "INVALID_PARAMS", "Controller params are invalid."));
336
+ send(controllerFailure(request.id, "INVALID_PARAMS", "Controller params are invalid."));
208
337
  return;
209
338
  }
210
339
  if (expectedPid !== undefined && expectedPid !== process.pid) {
211
- sendResponse(socket, controllerFailure(request.id, "CONTROLLER_OWNERSHIP_MISMATCH", `Controller PID ${process.pid} does not match the expected PID ${expectedPid}.`));
340
+ send(controllerFailure(request.id, "CONTROLLER_OWNERSHIP_MISMATCH", `Controller PID ${process.pid} does not match the expected PID ${expectedPid}.`));
212
341
  return;
213
342
  }
214
- sendResponse(socket, {
343
+ send({
215
344
  id: request.id,
216
345
  ok: true,
217
346
  result: {
@@ -221,21 +350,275 @@ async function routeRequest(socket, line, token, dispatcher, stop) {
221
350
  }, () => void stop());
222
351
  return;
223
352
  }
353
+ if (request.method === "controller.begin-handover") {
354
+ const params = parseBeginHandoverParams(request.params);
355
+ if (params === null) {
356
+ send(controllerFailure(request.id, "INVALID_PARAMS", "Controller params are invalid."));
357
+ return;
358
+ }
359
+ if (handover.fence !== null) {
360
+ send(controllerFailure(request.id, "CONTROLLER_HANDOVER_ACTIVE", "Controller is already fenced for a release handover."));
361
+ return;
362
+ }
363
+ const fencedAt = new Date().toISOString();
364
+ const owner = currentHandoverOwner();
365
+ const fence = Object.freeze({
366
+ schemaVersion: 1,
367
+ handoverId: params.handoverId,
368
+ phase: "fenced",
369
+ old: owner,
370
+ candidate: null,
371
+ fromReleaseId: params.fromReleaseId,
372
+ toReleaseId: params.toReleaseId,
373
+ createdAt: fencedAt,
374
+ updatedAt: fencedAt
375
+ });
376
+ // Write the durable fence BEFORE setting the in-memory fence. If the
377
+ // durable write fails, the Controller must not be left permanently fenced
378
+ // in memory (retry would get CONTROLLER_HANDOVER_ACTIVE with no durable
379
+ // state to roll back).
380
+ try {
381
+ writeHandoverFence(home, fence);
382
+ }
383
+ catch (error) {
384
+ const detail = error instanceof Error ? safeErrorMessage(error.message) : undefined;
385
+ send(controllerFailure(request.id, "INTERNAL_ERROR", `Failed to write the handover fence${detail === undefined ? "" : `: ${detail}`}`));
386
+ return;
387
+ }
388
+ handover.fence = { handoverId: params.handoverId, fencedAt };
389
+ send({
390
+ id: request.id,
391
+ ok: true,
392
+ result: handoverFenceResult(fence)
393
+ });
394
+ return;
395
+ }
396
+ if (request.method === "controller.commit-handover") {
397
+ const handoverId = parseHandoverIdParam(request.params);
398
+ if (handoverId === null) {
399
+ send(controllerFailure(request.id, "INVALID_PARAMS", "Controller params are invalid."));
400
+ return;
401
+ }
402
+ if (handover.fence === null || handover.fence.handoverId !== handoverId) {
403
+ send(controllerFailure(request.id, "CONTROLLER_HANDOVER_MISMATCH", "Controller handover fence does not match the requested handover."));
404
+ return;
405
+ }
406
+ const committedAt = new Date().toISOString();
407
+ const existing = readHandoverFenceOrNull(home);
408
+ if (existing !== null) {
409
+ try {
410
+ writeHandoverFence(home, Object.freeze({
411
+ ...existing,
412
+ phase: "committed",
413
+ updatedAt: committedAt
414
+ }));
415
+ }
416
+ catch (error) {
417
+ const detail = error instanceof Error ? safeErrorMessage(error.message) : undefined;
418
+ send(controllerFailure(request.id, "INTERNAL_ERROR", `Failed to commit the handover fence${detail === undefined ? "" : `: ${detail}`}`));
419
+ return;
420
+ }
421
+ }
422
+ // Keep the in-memory fence set until this process exits. The pointer has
423
+ // switched and the Controller is draining; clearing the fence here would
424
+ // reopen the mutation window on the old release before stop() completes.
425
+ // The in-memory state dies with the process.
426
+ send({
427
+ id: request.id,
428
+ ok: true,
429
+ result: { committed: true, pid: process.pid }
430
+ }, () => void stop());
431
+ return;
432
+ }
433
+ if (request.method === "controller.rollback-handover") {
434
+ const handoverId = parseHandoverIdParam(request.params);
435
+ if (handoverId === null) {
436
+ send(controllerFailure(request.id, "INVALID_PARAMS", "Controller params are invalid."));
437
+ return;
438
+ }
439
+ if (handover.fence === null || handover.fence.handoverId !== handoverId) {
440
+ send(controllerFailure(request.id, "CONTROLLER_HANDOVER_MISMATCH", "Controller handover fence does not match the requested handover."));
441
+ return;
442
+ }
443
+ const rolledBackAt = new Date().toISOString();
444
+ const existing = readHandoverFenceOrNull(home);
445
+ if (existing !== null) {
446
+ writeHandoverFence(home, Object.freeze({
447
+ ...existing,
448
+ phase: "rolled-back",
449
+ updatedAt: rolledBackAt
450
+ }));
451
+ }
452
+ handover.fence = null;
453
+ // The candidate overwrote runtime-identity.json while it ran; now that the
454
+ // handover is rolled back and the candidate is dead, restore this
455
+ // Controller's primary identity so the file does not keep pointing at the
456
+ // dead candidate's release/PID.
457
+ const primaryIdentity = getPrimaryIdentity();
458
+ if (primaryIdentity !== null) {
459
+ writeRuntimeIdentity(home, primaryIdentity);
460
+ }
461
+ send({
462
+ id: request.id,
463
+ ok: true,
464
+ result: { resumed: true, pid: process.pid }
465
+ });
466
+ return;
467
+ }
468
+ if (request.method === "controller.handover-state") {
469
+ if (!isEmptyParams(request.params)) {
470
+ send(controllerFailure(request.id, "INVALID_PARAMS", "Controller params are invalid."));
471
+ return;
472
+ }
473
+ const fence = readHandoverFenceOrNull(home);
474
+ send({
475
+ id: request.id,
476
+ ok: true,
477
+ result: {
478
+ fenced: handover.fence !== null,
479
+ ...(handover.fence === null ? {} : { handoverId: handover.fence.handoverId }),
480
+ ...(fence === null ? {} : { phase: fence.phase })
481
+ }
482
+ });
483
+ return;
484
+ }
224
485
  if (dispatcher === undefined) {
225
- sendResponse(socket, controllerFailure(request.id, "METHOD_NOT_FOUND", "Controller method was not found."));
486
+ send(controllerFailure(request.id, "METHOD_NOT_FOUND", "Controller method was not found."));
226
487
  return;
227
488
  }
228
489
  try {
229
490
  const result = await dispatcher(request.method, request.params);
230
- sendResponse(socket, { id: request.id, ok: true, result });
491
+ send({ id: request.id, ok: true, result });
231
492
  }
232
493
  catch (error) {
233
494
  const safeError = safeDispatcherError(error);
234
- sendResponse(socket, safeError === undefined
495
+ send(safeError === undefined
235
496
  ? controllerFailure(request.id, "INTERNAL_ERROR", "Controller request failed.")
236
497
  : controllerFailure(request.id, safeError.code, safeError.message));
237
498
  }
238
499
  }
500
+ function parseBeginHandoverParams(value) {
501
+ if (typeof value !== "object"
502
+ || value === null
503
+ || Array.isArray(value)) {
504
+ return null;
505
+ }
506
+ const record = value;
507
+ const handoverId = record.handoverId;
508
+ const toReleaseId = record.toReleaseId;
509
+ if (typeof handoverId !== "string"
510
+ || handoverId.length === 0
511
+ || handoverId.length > 128
512
+ || typeof toReleaseId !== "string"
513
+ || toReleaseId.length === 0
514
+ || toReleaseId.length > 256) {
515
+ return null;
516
+ }
517
+ const fromReleaseId = record.fromReleaseId;
518
+ if (fromReleaseId !== undefined
519
+ && (typeof fromReleaseId !== "string" || fromReleaseId.length > 256)) {
520
+ return null;
521
+ }
522
+ return {
523
+ handoverId,
524
+ fromReleaseId: typeof fromReleaseId === "string" ? fromReleaseId : null,
525
+ toReleaseId
526
+ };
527
+ }
528
+ function parseHandoverIdParam(value) {
529
+ if (typeof value !== "object"
530
+ || value === null
531
+ || Array.isArray(value)) {
532
+ return null;
533
+ }
534
+ const handoverId = value.handoverId;
535
+ return typeof handoverId === "string" && handoverId.length > 0 && handoverId.length <= 128
536
+ ? handoverId
537
+ : null;
538
+ }
539
+ function currentHandoverOwner() {
540
+ const release = detectRunningRelease(fileURLToPath(import.meta.url));
541
+ return Object.freeze({
542
+ pid: process.pid,
543
+ processStartIdentity: readLinuxProcessStartIdentitySync(process.pid),
544
+ buildId: release?.manifest.buildId ?? "dev",
545
+ version: YUI_VERSION
546
+ });
547
+ }
548
+ function handoverFenceResult(fence) {
549
+ return {
550
+ handoverId: fence.handoverId,
551
+ phase: fence.phase,
552
+ old: {
553
+ pid: fence.old.pid,
554
+ processStartIdentity: fence.old.processStartIdentity,
555
+ buildId: fence.old.buildId,
556
+ version: fence.old.version
557
+ },
558
+ toReleaseId: fence.toReleaseId
559
+ };
560
+ }
561
+ function readHandoverFenceOrNull(home) {
562
+ try {
563
+ return readHandoverFence(home);
564
+ }
565
+ catch {
566
+ return null;
567
+ }
568
+ }
569
+ /** Read the active release pointer without letting a corrupted file kill the Controller. */
570
+ function readActiveReleasePointerSafe(home) {
571
+ try {
572
+ return readActiveReleasePointer(home);
573
+ }
574
+ catch {
575
+ return null;
576
+ }
577
+ }
578
+ function readLinuxProcessStartIdentitySync(pid) {
579
+ // The async /proc reader is used at startup; handover RPCs need a sync
580
+ // identity to keep the request handler simple. Both parse the same field.
581
+ const processStartIdentity = parseLinuxProcessStartIdentity(readFileSync(`/proc/${pid}/stat`, "utf8"));
582
+ if (processStartIdentity === undefined) {
583
+ throw new Error(`Cannot read Controller process identity for PID ${pid}.`);
584
+ }
585
+ return processStartIdentity;
586
+ }
587
+ function controllerCliRealpath() {
588
+ return realpathSync(fileURLToPath(new URL("../cli.js", import.meta.url)));
589
+ }
590
+ export function buildRuntimeIdentityReceipt(input) {
591
+ const activeRelease = (() => {
592
+ try {
593
+ return readActiveReleasePointer(input.home);
594
+ }
595
+ catch {
596
+ return null;
597
+ }
598
+ })();
599
+ return Object.freeze({
600
+ schemaVersion: 1,
601
+ version: YUI_VERSION,
602
+ executablePath: process.execPath,
603
+ args: process.argv.slice(1),
604
+ buildId: input.release?.manifest.buildId ?? "dev",
605
+ packageDigest: input.release?.manifest.packageDigest ?? null,
606
+ sourceCommit: input.release?.manifest.sourceCommit ?? null,
607
+ cliRealpath: controllerCliRealpath(),
608
+ controllerRealpath: realpathSync(fileURLToPath(import.meta.url)),
609
+ controllerProtocolVersion: FILE_TASK_CONTROLLER_PROTOCOL_VERSION,
610
+ storageLayoutVersion: yuiVersionIdentity().storageLayoutVersion,
611
+ aggregateSchemaVersion: yuiVersionIdentity().aggregateSchemaVersion,
612
+ storageBackend: input.storageBackend,
613
+ workerEnabled: input.workerEnabled,
614
+ pid: process.pid,
615
+ processStartIdentity: input.processStartIdentity,
616
+ mode: input.mode,
617
+ dualOwner: input.dualOwner,
618
+ activeRelease,
619
+ writtenAt: new Date().toISOString()
620
+ });
621
+ }
239
622
  /**
240
623
  * `controller.stop` is public with `{}` params, but update's replacement
241
624
  * mismatch path may provide one exact PID fence. `null` means malformed;
@@ -337,6 +720,18 @@ function safeDispatcherError(error) {
337
720
  return { code: "SERVICE_ERROR", message };
338
721
  case "CoreJobError":
339
722
  return { code: "JOB_ERROR", message };
723
+ case "CommandExecutionError": {
724
+ // Transient tmux/process launch failures (e.g. under CI load) carry
725
+ // the real stderr; surface it instead of the generic fallback so the
726
+ // caller can distinguish a retryable infrastructure failure from a
727
+ // permanent contract violation.
728
+ if (error instanceof CommandExecutionError && error.stderr.length > 0) {
729
+ const detail = safeErrorMessage(error.stderr);
730
+ if (detail !== undefined)
731
+ return { code: "SERVICE_ERROR", message: detail };
732
+ }
733
+ return { code: "SERVICE_ERROR", message };
734
+ }
340
735
  default:
341
736
  return undefined;
342
737
  }
@@ -350,6 +745,7 @@ function safeApplicationErrorCode(code) {
350
745
  case "INVALID_PARAMS":
351
746
  case "METHOD_NOT_FOUND":
352
747
  case "NOT_FOUND":
748
+ case "UNAUTHORIZED":
353
749
  return code;
354
750
  default:
355
751
  return undefined;
@@ -453,15 +849,8 @@ function isProcessAlive(pid) {
453
849
  }
454
850
  }
455
851
  async function readLinuxProcessStartIdentity(pid) {
456
- const stat = await readFile(`/proc/${pid}/stat`, "utf8");
457
- const closingParenthesis = stat.lastIndexOf(")");
458
- if (closingParenthesis < 0) {
459
- throw new Error(`Cannot read Controller process identity for PID ${pid}.`);
460
- }
461
- const fieldsAfterCommand = stat.slice(closingParenthesis + 1).trim().split(/\s+/u);
462
- const processStartIdentity = fieldsAfterCommand[19];
463
- if (processStartIdentity === undefined
464
- || !/^[0-9]{1,32}$/u.test(processStartIdentity)) {
852
+ const processStartIdentity = parseLinuxProcessStartIdentity(await readFile(`/proc/${pid}/stat`, "utf8"));
853
+ if (processStartIdentity === undefined) {
465
854
  throw new Error(`Cannot read Controller process identity for PID ${pid}.`);
466
855
  }
467
856
  return processStartIdentity;