@zq-silk/yui 2.21.0 → 2.23.0

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 (145) hide show
  1. package/README.md +5 -0
  2. package/dist/cli/commandCatalog.js +87 -27
  3. package/dist/cli/dynamicCompletion.js +4 -1
  4. package/dist/cli/helpRenderer.js +12 -1
  5. package/dist/cli/interactionPolicy.js +2 -0
  6. package/dist/cli/invocationRouter.js +5 -5
  7. package/dist/cli/roleWizard.js +5 -1
  8. package/dist/cli/updateCommand.js +20 -3
  9. package/dist/cli/updateOrchestrator.js +13 -0
  10. package/dist/cli/updatePorts.js +27 -0
  11. package/dist/commands/globalRoleCommands.js +33 -8
  12. package/dist/commands/inboxCommands.js +39 -12
  13. package/dist/commands/operatorCommands.js +2 -5
  14. package/dist/commands/releaseCommands.js +16 -2
  15. package/dist/commands/roleConfiguration.js +25 -2
  16. package/dist/commands/sessionCommands.js +5 -2
  17. package/dist/commands/taskCommands.js +217 -64
  18. package/dist/commands/taskContextCommand.js +26 -4
  19. package/dist/commands/taskFactCommands.js +15 -2
  20. package/dist/commands/taskIntegrationCommands.js +3 -3
  21. package/dist/commands/taskRoleRuntimeStatus.js +19 -5
  22. package/dist/commands/textInput.js +2 -2
  23. package/dist/context/contextFamilies.js +10 -0
  24. package/dist/context/currentSkills.js +37 -4
  25. package/dist/context/runContextPack.js +44 -5
  26. package/dist/context/runInputContract.js +1 -1
  27. package/dist/context/sessionBootstrapManifest.js +21 -6
  28. package/dist/context/sessionSkillNotice.js +74 -0
  29. package/dist/context/taskContext.js +100 -16
  30. package/dist/controlPlaneCli.js +17 -7
  31. package/dist/controller/agentRuntimeObserver.js +57 -12
  32. package/dist/controller/clientRuntime.js +66 -18
  33. package/dist/controller/controller.js +19 -2
  34. package/dist/controller/diagnosticJournal.js +10 -2
  35. package/dist/controller/fileSchedulerStoreAdapter.js +74 -24
  36. package/dist/controller/hostLifecycle.js +153 -0
  37. package/dist/controller/hostLifecycleChecks.js +27 -0
  38. package/dist/controller/hostUpgrade.js +344 -0
  39. package/dist/controller/messageHealth.js +4 -3
  40. package/dist/controller/providerRetryAdmission.js +15 -1
  41. package/dist/controller/providerRetryDelivery.js +18 -2
  42. package/dist/controller/runtime.js +82 -21
  43. package/dist/controller/runtimeEventInbox.js +107 -13
  44. package/dist/controller/runtimeEventProcessor.js +3 -3
  45. package/dist/controller/sessionNotify.js +5 -46
  46. package/dist/controller/taskRunRecovery.js +153 -0
  47. package/dist/core/controllerClient.js +16 -2
  48. package/dist/core/controllerServer.js +14 -5
  49. package/dist/core/controllerTelemetry.js +51 -5
  50. package/dist/errors/cliFailure.js +14 -5
  51. package/dist/execution/codexThreadNaming.js +54 -43
  52. package/dist/executor/agentAdapter.js +32 -4
  53. package/dist/executor/agentConfigurationFields.js +2 -0
  54. package/dist/executor/fileRoleLaunchPlanner.js +18 -3
  55. package/dist/integration/gitIntegrationService.js +3 -2
  56. package/dist/integration/integrationSourceApplication.js +12 -1
  57. package/dist/interaction/notificationPresentation.js +11 -16
  58. package/dist/interaction/operatorPresentation.js +1 -1
  59. package/dist/message/messageContinuation.js +3 -1
  60. package/dist/output/boundedRead.js +36 -7
  61. package/dist/output/rolePresentation.js +3 -1
  62. package/dist/runtime/agentEndpoint.js +11 -1
  63. package/dist/runtime/agentEndpointIdentity.js +2 -0
  64. package/dist/runtime/agentHost.js +174 -40
  65. package/dist/runtime/agentHostImplementation.js +3 -1
  66. package/dist/runtime/codexAppServerRuntime.js +113 -24
  67. package/dist/runtime/codexInteractiveHost.js +102 -11
  68. package/dist/runtime/codexNativeAccess.js +1 -1
  69. package/dist/runtime/contextRecovery.js +15 -0
  70. package/dist/runtime/hostSessionLifecycle.js +83 -0
  71. package/dist/runtime/hostUpgradeGate.js +43 -0
  72. package/dist/runtime/lifecycleChecks.js +47 -0
  73. package/dist/runtime/lifecycleReservation.js +9 -1
  74. package/dist/runtime/nativeTitleReceipt.js +31 -0
  75. package/dist/runtime/nativeTitleSync.js +192 -0
  76. package/dist/runtime/providerRetry.js +9 -6
  77. package/dist/runtime/providerRuntimeIdentity.js +1 -1
  78. package/dist/runtime/structuredProviderHost.js +65 -35
  79. package/dist/scheduler/wakeupQueue.js +10 -4
  80. package/dist/storage/sqliteSchema.js +29 -0
  81. package/dist/storage/storageVersions.js +1 -1
  82. package/dist/tmux/tmuxManager.js +4 -2
  83. package/dist/web/webSettings.js +2 -0
  84. package/docs/authorization.md +18 -3
  85. package/docs/cli-information-contract.md +228 -6
  86. package/docs/cli-information-contract.zh-CN.md +95 -5
  87. package/docs/managed-turn-and-session-runtime.md +177 -21
  88. package/docs/managed-turn-and-session-runtime.zh-CN.md +126 -14
  89. package/docs/provider-retry.md +11 -0
  90. package/docs/release-workflow.md +65 -0
  91. package/docs/release-workflow.zh-CN.md +48 -0
  92. package/docs/roles-and-configuration.md +28 -0
  93. package/docs/roles-and-configuration.zh-CN.md +21 -0
  94. package/docs/storage-baseline.md +13 -1
  95. package/docs/storage-baseline.zh-CN.md +5 -1
  96. package/docs/task-delivery.md +7 -0
  97. package/docs/task-delivery.zh-CN.md +5 -0
  98. package/docs/testing/review-evaluation.md +7 -7
  99. package/docs/testing/review-evaluation.zh-CN.md +7 -7
  100. package/docs/testing/verification-levels.md +45 -2
  101. package/docs/testing/verification-levels.zh-CN.md +21 -2
  102. package/i18n/README.zh-CN.md +6 -0
  103. package/package.json +1 -1
  104. package/skills/yui-leader/SKILL.md +98 -193
  105. package/skills/yui-leader/VERSION +1 -1
  106. package/skills/yui-leader/references/artifacts.md +41 -0
  107. package/skills/yui-leader/references/assignment-execution.md +140 -0
  108. package/skills/yui-leader/references/completion.md +79 -0
  109. package/skills/yui-leader/references/delivery-scope.md +26 -0
  110. package/skills/yui-leader/references/execution.md +16 -487
  111. package/skills/yui-leader/references/implementation.md +81 -0
  112. package/skills/yui-leader/references/planning.md +7 -45
  113. package/skills/yui-leader/references/review.md +90 -0
  114. package/skills/yui-leader/references/task-progress.md +23 -0
  115. package/skills/yui-leader/references/work-allocation.md +118 -0
  116. package/skills/yui-operator/SKILL.md +77 -333
  117. package/skills/yui-operator/VERSION +1 -1
  118. package/skills/yui-operator/references/body-transport.md +62 -0
  119. package/skills/yui-operator/references/configuration.md +47 -0
  120. package/skills/yui-operator/references/lifecycle.md +41 -0
  121. package/skills/yui-operator/references/progress.md +39 -0
  122. package/skills/yui-operator/references/routing.md +57 -0
  123. package/skills/yui-operator/references/submission.md +56 -0
  124. package/skills/yui-operator/references/task-delivery.md +1 -1
  125. package/skills/yui-operator/references/web-lifecycle.md +19 -0
  126. package/skills/yui-operator/scripts/verify-submission.mjs +37 -0
  127. package/skills/yui-runtime/SKILL.md +182 -531
  128. package/skills/yui-runtime/VERSION +1 -1
  129. package/skills/yui-runtime/references/archive.md +51 -0
  130. package/skills/yui-runtime/references/command-discovery.md +41 -0
  131. package/skills/yui-runtime/references/completed-followup.md +65 -0
  132. package/skills/yui-runtime/references/context-ordering.md +3 -34
  133. package/skills/yui-runtime/references/context-reads.md +61 -0
  134. package/skills/yui-runtime/references/dispatched-run.md +61 -0
  135. package/skills/yui-runtime/references/execution-failure.md +105 -0
  136. package/skills/yui-runtime/references/global-context.md +18 -0
  137. package/skills/yui-runtime/references/global-input-controls.md +44 -0
  138. package/skills/yui-runtime/references/host-custody.md +30 -0
  139. package/skills/yui-runtime/references/inbox-recovery.md +77 -0
  140. package/skills/yui-runtime/references/incremental-context.md +40 -0
  141. package/skills/yui-runtime/references/publication.md +4 -111
  142. package/skills/yui-runtime/references/recovery.md +32 -192
  143. package/skills/yui-runtime/references/session-identity.md +18 -0
  144. package/skills/yui-runtime/references/session-replacement.md +45 -0
  145. package/skills/yui-runtime/references/uncertain-execution.md +29 -0
@@ -4,6 +4,7 @@ import { randomUUID } from "node:crypto";
4
4
  import { readFileSync, realpathSync } from "node:fs";
5
5
  import { resolve } from "node:path";
6
6
  import { createInterface } from "node:readline/promises";
7
+ import { isatty } from "node:tty";
7
8
  import { fileURLToPath } from "node:url";
8
9
  import { isDeepStrictEqual } from "node:util";
9
10
  import { agentAdapterLabel as adapterLabel } from "./agent/adapterCatalog.js";
@@ -11,14 +12,14 @@ import { recordTaskInterruptResult } from "./message/taskInterrupt.js";
11
12
  import { nativeAgentEnvironmentNames } from "./agent/launchEnvironment.js";
12
13
  import { listArtifactsCapability, readArtifactCapability, saveArtifactCapability } from "./artifacts/artifactCapability.js";
13
14
  import { renderAgentConfigurationResolutionNotice } from "./cli/agentConfigurationPicker.js";
14
- import { describeCommandTree, findCommandNode } from "./cli/commandCatalog.js";
15
+ import { describeCommandHelp, describeCommandTree, findCommandNode } from "./cli/commandCatalog.js";
15
16
  import { discoveryCommandTree } from "./cli/commandDiscovery.js";
16
17
  import { callerDiscoveryAudience } from "./cli/callerDiscovery.js";
17
18
  import { assertTaskReadable } from "./context/taskContext.js";
18
19
  import { renderCompletion } from "./cli/completion.js";
19
20
  import { runCompletionWizard } from "./cli/completionWizard.js";
20
21
  import { resolveCompletionCandidates } from "./cli/dynamicCompletion.js";
21
- import { renderCommandHelp } from "./cli/helpRenderer.js";
22
+ import { renderCommandHelp, renderCommandPathHint } from "./cli/helpRenderer.js";
22
23
  import { allowsInteractiveSelection, resolveInteractiveArguments } from "./cli/interactiveSelection.js";
23
24
  import { routeInvocation } from "./cli/invocationRouter.js";
24
25
  import { operatorOfflineCommand, isTaskReadCommand } from "./cli/managedDiagnostics.js";
@@ -202,7 +203,7 @@ export async function main() {
202
203
  return;
203
204
  }
204
205
  if (args.length === 0) {
205
- emit(renderCommandHelp(discovery, VERSION), false, jsonOutput ? describeCommandTree(discovery) : undefined);
206
+ emit(renderCommandHelp(discovery, VERSION), false, jsonOutput ? describeCommandHelp(discovery) : undefined);
206
207
  return;
207
208
  }
208
209
  if (args[0] === "version" && args.length === 1) {
@@ -211,11 +212,19 @@ export async function main() {
211
212
  }
212
213
  const invocation = routeInvocation(args, discovery);
213
214
  if (invocation.kind === "help") {
214
- emit(renderCommandHelp(invocation.node, VERSION), true, jsonOutput ? describeCommandTree(invocation.node) : undefined);
215
+ if (invocation.recursive && !jsonOutput)
216
+ throw usageError("Recursive help requires --json.");
217
+ emit(renderCommandHelp(invocation.node, VERSION), true, jsonOutput
218
+ ? invocation.recursive ? describeCommandTree(invocation.node) : describeCommandHelp(invocation.node)
219
+ : undefined);
215
220
  return;
216
221
  }
217
222
  if (invocation.kind === "path-error") {
218
- throw usageError(`Unknown command: ${invocation.typedPath}`, renderCommandHelp(invocation.helpNode, VERSION));
223
+ throw usageError(invocation.discoveryOnly
224
+ ? `Command not listed in the current caller's help view: ${invocation.typedPath}. `
225
+ + "Help is filtered discovery, not an execution or permission check. "
226
+ + "Follow current instructions and exact read links; execution still validates authority."
227
+ : `Unknown command: ${invocation.typedPath}`, renderCommandPathHint(invocation.helpNode));
219
228
  }
220
229
  if (invocation.kind === "incomplete") {
221
230
  throw usageError(`Command required after: ${invocation.typedPath}`, renderCommandHelp(invocation.helpNode, VERSION));
@@ -660,7 +669,6 @@ export async function main() {
660
669
  const tmux = new TmuxManager(resolveTmuxBin(store.getConfig().tmuxBin), executor, {
661
670
  yuiHome: home,
662
671
  historyLimit: resolveTmuxHistoryLimit(store.getConfig().tmuxHistoryLimit),
663
- terminalInput: process.stdin,
664
672
  onWarning: (message) => writeCliWarning(message)
665
673
  });
666
674
  const telemetry = openSchedulerTelemetry(home, store.getConfig());
@@ -2750,7 +2758,9 @@ function completionShell(value) {
2750
2758
  throw usageError("Completion shell must be one of bash, zsh, fish.");
2751
2759
  }
2752
2760
  async function resolveTerminalArguments(commandArgs, node, store, catalogs) {
2753
- const interactive = process.stdin.isTTY === true && process.stdout.isTTY === true;
2761
+ // Probe the descriptor without constructing a stdin stream: Node makes
2762
+ // pipes nonblocking on stream creation, breaking later --body-file - reads.
2763
+ const interactive = isatty(0) && process.stdout.isTTY === true;
2754
2764
  if (!interactive || !allowsInteractiveSelection(commandArgs, jsonOutput)) {
2755
2765
  return [...commandArgs];
2756
2766
  }
@@ -15,14 +15,45 @@ export class AgentRuntimeObserver {
15
15
  #states = new Map();
16
16
  #maxConcurrentSamples;
17
17
  #sequence = 0;
18
+ #samples = 0;
19
+ #sources = 0;
20
+ #published = 0;
21
+ #usagePublications = 0;
22
+ #failed = 0;
18
23
  constructor(store, inbox, drivers = builtinAgentDriverRegistry(), options = {}) {
19
24
  this.store = store;
20
25
  this.inbox = inbox;
21
26
  this.drivers = drivers;
22
27
  this.#maxConcurrentSamples = sampleConcurrency(options.maxConcurrentSamples);
23
28
  }
24
- async sample(now = new Date()) {
25
- const active = this.activeSources();
29
+ async sample(now = new Date(), phases) {
30
+ this.#samples++;
31
+ const finishDiscovery = phases?.start("runtime-sample-discovery", "home");
32
+ let active;
33
+ try {
34
+ active = this.activeSources();
35
+ finishDiscovery?.();
36
+ }
37
+ catch (error) {
38
+ finishDiscovery?.(true);
39
+ throw error;
40
+ }
41
+ this.#sources += active.length;
42
+ const publish = async (observation) => {
43
+ try {
44
+ const result = await (phases === undefined
45
+ ? this.inbox.enqueueObservationAsync(observation)
46
+ : phases.observe(observation.payload.usage === undefined ? "runtime-sample-publish" : "runtime-usage-publish", undefined, () => this.inbox.enqueueObservationAsync(observation)));
47
+ this.#published++;
48
+ if (observation.payload.usage !== undefined)
49
+ this.#usagePublications++;
50
+ return result;
51
+ }
52
+ catch (error) {
53
+ this.#failed++;
54
+ throw error;
55
+ }
56
+ };
26
57
  const activeKeys = new Set(active.map(({ key }) => key));
27
58
  for (const key of this.#states.keys()) {
28
59
  if (!activeKeys.has(key))
@@ -37,7 +68,10 @@ export class AgentRuntimeObserver {
37
68
  // usage/activity baseline is durable. Rehydrate it after Controller
38
69
  // restart so rereading the bounded transcript tail cannot manufacture a
39
70
  // fresh activity edge from tokens that were already observed.
40
- const state = existingState ?? { ...persistedState };
71
+ // Commit process-local state only when every fact from this sample is
72
+ // durable. A failed later publication must leave the old cursor usable;
73
+ // retry deduplicates any already-persisted prefix.
74
+ const state = { ...(existingState ?? persistedState) };
41
75
  const restoredUsageEventId = existingState === undefined
42
76
  ? persistedState.usageEventId
43
77
  : undefined;
@@ -63,7 +97,6 @@ export class AgentRuntimeObserver {
63
97
  detail: error instanceof Error ? error.message : String(error)
64
98
  });
65
99
  }
66
- state.cursor = sample.cursor;
67
100
  const at = now.toISOString();
68
101
  // Reserve sequence numbers in source-key order before sampling. Provider
69
102
  // latency can change completion order without changing observation
@@ -101,7 +134,7 @@ export class AgentRuntimeObserver {
101
134
  const identity = completeHistory
102
135
  ? tokenObservationIdentity("baseline", fence, source.sourceId, baselineKey)
103
136
  : usageObservationIdentity(fence, source.sourceId, latestOccurrence);
104
- this.inbox.enqueueObservation(createRuntimeObservation({
137
+ await publish(createRuntimeObservation({
105
138
  schemaVersion: 1,
106
139
  eventId: identity.eventId,
107
140
  semanticKey: identity.semanticKey,
@@ -136,7 +169,7 @@ export class AgentRuntimeObserver {
136
169
  }
137
170
  const health = JSON.stringify([sample.status, sample.detail ?? null]);
138
171
  if (state.health !== health) {
139
- this.inbox.enqueueObservation(createRuntimeObservation({
172
+ await publish(createRuntimeObservation({
140
173
  schemaVersion: 1,
141
174
  eventId: observationId("health", fence, source.sourceId, health),
142
175
  semanticKey: observationId("health", fence, source.sourceId, health),
@@ -180,9 +213,9 @@ export class AgentRuntimeObserver {
180
213
  : Object.freeze({ ...occurrence, observationQuality: "partial" })));
181
214
  }
182
215
  }
183
- usages.forEach((occurrence, usageIndex) => {
216
+ for (const [usageIndex, occurrence] of usages.entries()) {
184
217
  const identity = usageObservationIdentity(fence, source.sourceId, occurrence);
185
- this.inbox.enqueueObservation(createRuntimeObservation({
218
+ await publish(createRuntimeObservation({
186
219
  schemaVersion: 1,
187
220
  eventId: identity.eventId,
188
221
  semanticKey: identity.semanticKey,
@@ -210,9 +243,9 @@ export class AgentRuntimeObserver {
210
243
  state.usageOccurrenceId = occurrence.occurrenceId;
211
244
  state.usageOccurrenceCheckpoint = occurrence.resumeCheckpoint;
212
245
  }
213
- });
214
- if (activityChanged && state.cursor !== undefined) {
215
- this.inbox.enqueueObservation(createRuntimeObservation({
246
+ }
247
+ if (activityChanged && sample.cursor !== undefined) {
248
+ await publish(createRuntimeObservation({
216
249
  schemaVersion: 1,
217
250
  eventId: observationId("activity", fence, source.sourceId, sample.activityId),
218
251
  semanticKey: observationId("activity", fence, source.sourceId, sample.activityId),
@@ -232,10 +265,17 @@ export class AgentRuntimeObserver {
232
265
  }
233
266
  if (sample.activityId !== undefined)
234
267
  state.activityId = sample.activityId;
268
+ state.cursor = sample.cursor;
235
269
  this.#states.set(key, state);
236
270
  });
237
271
  return Object.freeze([...dirty].sort(numericCompare));
238
272
  }
273
+ /** Counts successful durable admissions (including duplicates), not tokens or
274
+ * provider content. Phase observations carry discovery/publication duration. */
275
+ metrics() {
276
+ return { samples: this.#samples, sources: this.#sources, published: this.#published,
277
+ usagePublications: this.#usagePublications, failed: this.#failed };
278
+ }
239
279
  activeSources() {
240
280
  const result = [];
241
281
  const activeTasks = [...new Set(this.store.listActiveTaskIds())]
@@ -354,13 +394,18 @@ export class AgentRuntimeObserver {
354
394
  async function forEachConcurrent(values, limit, visit) {
355
395
  let nextIndex = 0;
356
396
  const workers = Math.min(limit, values.length);
357
- await Promise.all(Array.from({ length: workers }, async () => {
397
+ const results = await Promise.allSettled(Array.from({ length: workers }, async () => {
358
398
  while (nextIndex < values.length) {
359
399
  const index = nextIndex;
360
400
  nextIndex += 1;
361
401
  await visit(values[index], index);
362
402
  }
363
403
  }));
404
+ // An admission failure must not release the Controller's in-flight sample
405
+ // guard while another source can still commit state from that sample.
406
+ const failure = results.find(result => result.status === "rejected");
407
+ if (failure?.status === "rejected")
408
+ throw failure.reason;
364
409
  }
365
410
  function sampleConcurrency(value) {
366
411
  const resolved = value ?? DEFAULT_MAX_CONCURRENT_SAMPLES;
@@ -2,7 +2,8 @@ import { spawn } from "node:child_process";
2
2
  import { jobCleanupBlocker } from "../job/jobCleanupInspection.js";
3
3
  import { describeCliFailure } from "../errors/cliFailure.js";
4
4
  import { fileURLToPath } from "node:url";
5
- import { findLiveControllerProcessForHome, inspectLiveControllerProcess } from "../core/controllerProcessIdentity.js";
5
+ import { findLiveControllerProcessForHome } from "../core/controllerProcessIdentity.js";
6
+ import { processGenerationIsLive } from "../core/fileLockOwner.js";
6
7
  import { readHomeFilesystemId } from "../core/homeFilesystemIdentity.js";
7
8
  import { parseControllerIdentity } from "../core/controllerIdentity.js";
8
9
  import { AGENT_OPERATIONAL_ENVIRONMENT_NAMES, nativeAgentEnvironmentNames, operationalAgentEnvironment, selectEnvironment, YUI_MANAGED_RUNTIME_ENVIRONMENT_NAMES } from "../agent/launchEnvironment.js";
@@ -12,6 +13,7 @@ import { isForeignHandoverLockHeld } from "../release/runtimeRelease.js";
12
13
  import { WorkspaceCleanupBlockedError } from "../repository/taskWorkspacePreparer.js";
13
14
  import { hasRuntimeLifecycleWork } from "../runtime/lifecycleReservation.js";
14
15
  import { assertControllerStatusIdentity } from "../runtime/runtimeCoherence.js";
16
+ import { readLinuxProcessIdentity } from "../runtime/sessionOwnerIdentity.js";
15
17
  import { CONTROLLER_SHUTDOWN_TIMEOUT_MS, LIFECYCLE_REQUEST_TIMEOUT_MS } from "../runtime/runtimeDeadlines.js";
16
18
  import { openCurrentTaskStore } from "../storage/currentTaskStore.js";
17
19
  import { yuiVersionIdentity } from "../version.js";
@@ -57,21 +59,31 @@ export async function assertFileTaskControllerStorageCompatible(home, options =
57
59
  export async function ensureFileTaskController(home, options = {}) {
58
60
  const call = options.call ?? callController;
59
61
  await waitForForeignHandover(home, options);
60
- try {
61
- const status = await call(home, "controller.status", {});
62
- assertCompatibleControllerStatus(status, options.expectedIdentity);
63
- return status;
64
- }
65
- catch (error) {
66
- if (!isUnavailable(error))
67
- throw error;
62
+ const timeoutMs = positive(options.startupTimeoutMs, STARTUP_TIMEOUT_MS, "startupTimeoutMs");
63
+ const pollMs = positive(options.pollIntervalMs, POLL_INTERVAL_MS, "pollIntervalMs");
64
+ const probeDeadline = Date.now() + timeoutMs;
65
+ for (;;) {
66
+ try {
67
+ const status = await call(home, "controller.status", {});
68
+ assertCompatibleControllerStatus(status, options.expectedIdentity);
69
+ return status;
70
+ }
71
+ catch (error) {
72
+ if (isUnavailable(error))
73
+ break;
74
+ // A closing endpoint may reset a read even after its owner exited.
75
+ // Retry only this observation: ambiguity never authorizes a spawn.
76
+ if (!(error instanceof ControllerClientError)
77
+ || error.code !== "CONTROLLER_DELIVERY_UNKNOWN"
78
+ || Date.now() >= probeDeadline)
79
+ throw error;
80
+ }
81
+ await delay(pollMs);
68
82
  }
69
83
  // A handover may have started after the first lock check and before the
70
84
  // failed discovery/socket call. Re-check before spawning so a managed
71
85
  // Session can never resurrect the old Controller during an update.
72
86
  await waitForForeignHandover(home, options);
73
- const timeoutMs = positive(options.startupTimeoutMs, STARTUP_TIMEOUT_MS, "startupTimeoutMs");
74
- const pollMs = positive(options.pollIntervalMs, POLL_INTERVAL_MS, "pollIntervalMs");
75
87
  const spawnController = options.spawnController ?? spawnDetachedFileTaskController;
76
88
  // A readiness timeout is not exit evidence. Reuse the observable startup
77
89
  // process for this exact Home rather than multiplying unready Controllers.
@@ -169,13 +181,27 @@ export async function stopFileTaskController(home, options = {}) {
169
181
  await waitForForeignHandover(home, options);
170
182
  const physical = options.call === undefined
171
183
  ? findLiveControllerProcessForHome(readHomeFilesystemId(home)) : undefined;
172
- const expectedPid = options.expectedPid ?? physical?.pid;
184
+ // A protected, Home-validated discovery can retain the generation fence
185
+ // even if the optional /proc ownership inventory is currently unreadable.
186
+ // It is not signal authority; physical recovery still revalidates ownership.
187
+ let captured = physical;
188
+ if (captured === undefined && options.call === undefined) {
189
+ try {
190
+ const discovery = await readControllerDiscovery(home);
191
+ captured = { pid: discovery.pid, processStartIdentity: discovery.processStartIdentity };
192
+ }
193
+ catch (error) {
194
+ if (!isDefinitelyNotRunning(error))
195
+ throw error;
196
+ }
197
+ }
198
+ const expectedPid = options.expectedPid ?? captured?.pid;
173
199
  const bounded = { ...options, requestTimeoutMs: options.requestTimeoutMs ?? 2000,
174
200
  ...(expectedPid === undefined ? {} : { expectedPid }) };
175
201
  try {
176
202
  const result = await stopControllerGracefully(home, bounded);
177
- if (result.stopped && physical !== undefined) {
178
- await waitForControllerProcessExit(home, physical, options);
203
+ if (result.stopped && captured !== undefined) {
204
+ await waitForControllerProcessExit(captured, options);
179
205
  }
180
206
  if (result.stopped || options.call !== undefined)
181
207
  return result;
@@ -185,16 +211,38 @@ export async function stopFileTaskController(home, options = {}) {
185
211
  throw error;
186
212
  }
187
213
  const stopped = await stopOrphanedFileTaskController(home, options.shutdownTimeoutMs ?? CONTROLLER_SHUTDOWN_TIMEOUT_MS, { force: true, ...(expectedPid === undefined ? {} : { expectedPid }),
188
- ...(physical === undefined ? {} : { expectedProcessStartIdentity: physical.processStartIdentity }) });
214
+ ...(captured === undefined ? {} : { expectedProcessStartIdentity: captured.processStartIdentity }) });
215
+ // No match is not exit proof: /proc ownership inspection may have become
216
+ // unreadable after we captured this exact generation. Physical recovery
217
+ // cannot authorize a successor until that original generation is gone.
218
+ if (captured !== undefined)
219
+ await waitForControllerProcessExit(captured, options);
189
220
  return stopped === undefined ? { stopped: false, alreadyStopped: true } : { stopped: true, pid: stopped.pid };
190
221
  }
191
- async function waitForControllerProcessExit(home, controller, options) {
222
+ async function waitForControllerProcessExit(controller, options) {
192
223
  const timeoutMs = positive(options.shutdownTimeoutMs, CONTROLLER_SHUTDOWN_TIMEOUT_MS, "shutdownTimeoutMs");
193
224
  const pollMs = positive(options.pollIntervalMs, POLL_INTERVAL_MS, "pollIntervalMs");
194
- const homeFilesystemId = readHomeFilesystemId(home);
195
225
  const deadline = Date.now() + timeoutMs;
196
- while (inspectLiveControllerProcess(controller.pid, homeFilesystemId, controller.processStartIdentity) !== undefined) {
226
+ for (;;) {
227
+ let observationError;
228
+ try {
229
+ if (!processGenerationIsLive(controller.pid, controller.processStartIdentity))
230
+ return;
231
+ if (process.platform === "linux") {
232
+ const observed = readLinuxProcessIdentity(controller.pid);
233
+ // An exact zombie has exited even before its parent reaps it.
234
+ if (observed?.startIdentity === controller.processStartIdentity && observed.state === "Z")
235
+ return;
236
+ }
237
+ }
238
+ catch (error) {
239
+ // OS identity can disappear before kill(pid, 0) reports absence.
240
+ // Keep observing within the existing stop budget, never infer exit.
241
+ observationError = error;
242
+ }
197
243
  if (Date.now() >= deadline) {
244
+ if (observationError !== undefined)
245
+ throw observationError;
198
246
  throw new ControllerClientError("CONTROLLER_TIMEOUT", `Controller process ${controller.pid} did not exit within ${timeoutMs} ms.`);
199
247
  }
200
248
  await delay(pollMs);
@@ -14,7 +14,7 @@ import { KeyedWorkQueue } from "../coordination/keyedWorkQueue.js";
14
14
  import { MailboxScheduler } from "../coordination/mailboxScheduler.js";
15
15
  import { nearestDeadlineBatch } from "../coordination/deadlineScheduler.js";
16
16
  import { mailboxHasWork, nextPendingBatch } from "../coordination/workMailbox.js";
17
- import { hasRuntimeCleanupObligation } from "../runtime/lifecycleReservation.js";
17
+ import { hasRuntimeCleanupObligation, runtimeCleanupDisposition } from "../runtime/lifecycleReservation.js";
18
18
  import { formatTaskRecordReference } from "../task/taskRecordReference.js";
19
19
  import { parseDurableJobAcknowledgeParams, parseDurableJobCancelParams, parseDurableJobRefParams, parseDurableJobStartParams } from "./jobControl.js";
20
20
  const DEFAULT_RECONCILIATION_INTERVAL_MS = reconciliationIntervalMilliseconds();
@@ -211,6 +211,12 @@ async function processSelectedRoleRuntimeCleanups(store, delivery, lifecycleHost
211
211
  const owner = runtimeOwner(target);
212
212
  const batchId = runtimeLifecycleBatchIdentity(target, mailbox);
213
213
  if (hasRuntimeCleanupObligation(mailbox)) {
214
+ // Upgrade is a graceful, exact-identity operation, never the destructive
215
+ // cleanup path. Its coordinator owns drain/restore; retain backpressure.
216
+ if (runtimeCleanupDisposition(mailbox) === "upgrade-host") {
217
+ markFailedRuntimeTarget(failedRoles, target);
218
+ continue;
219
+ }
214
220
  try {
215
221
  if (lifecycleHost === undefined) {
216
222
  throw new Error("Role runtime cleanup host is unavailable.");
@@ -698,6 +704,7 @@ export class FileTaskController {
698
704
  #providerRetry;
699
705
  #continuationReconciler;
700
706
  #hostTerminalRecovery;
707
+ #lifecycleChecks;
701
708
  #persistenceMetrics;
702
709
  #diagnosticMetrics;
703
710
  #current;
@@ -754,6 +761,7 @@ export class FileTaskController {
754
761
  this.#schedules = options.schedules;
755
762
  this.#continuationReconciler = options.continuationReconciler;
756
763
  this.#hostTerminalRecovery = options.hostTerminalRecovery;
764
+ this.#lifecycleChecks = options.lifecycleChecks;
757
765
  this.#globalInputDelivery = options.globalInputDelivery;
758
766
  this.#messageHealth = options.messageHealth;
759
767
  this.#providerRetry = options.providerRetry;
@@ -792,6 +800,7 @@ export class FileTaskController {
792
800
  runtimeMetrics() {
793
801
  const metrics = this.#lastRuntimeDrain?.metrics ?? ZERO_DRAIN_METRICS;
794
802
  return {
803
+ sampler: this.#runtimeObserver?.metrics?.() ?? null,
795
804
  messageHealth: this.#lastMessageHealth,
796
805
  diagnostics: this.#diagnosticMetrics?.() ?? null,
797
806
  phases: this.#phases.snapshot(),
@@ -841,6 +850,7 @@ export class FileTaskController {
841
850
  return;
842
851
  if (this.#stopped)
843
852
  throw new Error("Controller runtime is stopped.");
853
+ this.#lifecycleChecks?.notify("startup");
844
854
  this.#requestBackgroundPump();
845
855
  this.#requestRuntimeObservation();
846
856
  // Arm the Operator lane once for work that was already pending when the
@@ -861,6 +871,7 @@ export class FileTaskController {
861
871
  }
862
872
  stop() {
863
873
  this.#stopped = true;
874
+ this.#lifecycleChecks?.close();
864
875
  this.#workspaceWaitAbort.abort(new Error("Controller stopped while waiting for Project maintenance."));
865
876
  this.#signalScheduler.stop();
866
877
  this.#operatorSignalScheduler.stop();
@@ -904,6 +915,7 @@ export class FileTaskController {
904
915
  if (this.#stopped)
905
916
  throw new Error("Controller runtime is stopped.");
906
917
  parseMailboxKey(key);
918
+ this.#lifecycleChecks?.notify("signal");
907
919
  if (key === "operator") {
908
920
  // Main lane owns durable Hook folding; the Operator lane is the sole
909
921
  // consumer of the Operator delivery mailbox.
@@ -920,7 +932,7 @@ export class FileTaskController {
920
932
  if (this.#stopped || this.#runtimeObserver === undefined
921
933
  || this.#runtimeObserverCurrent !== undefined)
922
934
  return;
923
- const running = this.#runtimeObserver.sample(this.#now()).then((keys) => {
935
+ const running = this.#phases.observe("runtime-sample", "home", () => this.#runtimeObserver.sample(this.#now(), this.#phases)).then((keys) => {
924
936
  if (this.#stopped)
925
937
  return;
926
938
  for (const key of keys)
@@ -1016,6 +1028,8 @@ export class FileTaskController {
1016
1028
  }
1017
1029
  }
1018
1030
  const firstRuntimeDrain = await this.#phases.observe("runtime-drain", "home", () => this.#drainRuntimeEvents());
1031
+ if (scope.kind === "full")
1032
+ this.#lifecycleChecks?.notify("periodic");
1019
1033
  if (scope.kind === "full" && this.#hostTerminalRecovery !== undefined) {
1020
1034
  await this.#observeMaintenance("Host terminal recovery", this.#hostTerminalRecovery);
1021
1035
  }
@@ -1257,6 +1271,8 @@ export class FileTaskController {
1257
1271
  ? await processor.drainAsync(this.#now())
1258
1272
  : processor.drain(this.#now());
1259
1273
  this.#lastRuntimeDrain = result;
1274
+ if (result.acknowledgedEventIds.length > 0)
1275
+ this.#lifecycleChecks?.notify("events-persisted");
1260
1276
  this.#runtimeDrainPasses += 1;
1261
1277
  const metrics = result.metrics ?? ZERO_DRAIN_METRICS;
1262
1278
  this.#runtimeListedEvents += metrics.listedEventCount;
@@ -1779,6 +1795,7 @@ export async function startFileTaskController(home, store, delivery, dispatcher,
1779
1795
  await runtime.shutdownAndDrain();
1780
1796
  }, {
1781
1797
  domainIdentity: options.domainIdentity,
1798
+ onRequestDiagnostic: options.onRequestDiagnostic,
1782
1799
  status: ({ commandObserver, eventLoopDelay }) => ({
1783
1800
  ...runtime.runtimeMetrics(),
1784
1801
  commands: {
@@ -13,13 +13,15 @@ const NUMERIC_KEYS = new Set([
13
13
  "completed", "failed", "received", "inFlight", "maximumMs", "maximumLagMs",
14
14
  "maximumObservedAtMs", "oldestInFlightAgeMs", "startedAtMs", "elapsedMs",
15
15
  "ageMs", "depth", "semanticDepth", "progressDepth", "pendingKeys", "queueDepth",
16
- "hits", "rebuilds", "invalidations", "passes", "listedEvents", "selectedEvents"
16
+ "hits", "rebuilds", "invalidations", "passes", "listedEvents", "selectedEvents",
17
+ "acceptedAtMs", "receivedAtMs", "routedAtMs", "handlerCompletedAtMs", "flushCompletedAtMs",
18
+ "handlerFailed", "flushFailed", "samples", "sources", "published", "usagePublications"
17
19
  ]);
18
20
  const CONTAINER_KEYS = new Set([
19
21
  "commands", "observations", "routes", "eventLoopDelay", "phases", "reads",
20
22
  "persistence", "main", "worker", "database", "roundTrips", "work", "body",
21
23
  "lock", "commit", "payloadReads", "inbox", "scheduler", "projectionCache",
22
- "active", "recent", "slowest", "latest"
24
+ "active", "recent", "slowest", "latest", "transport", "sampler"
23
25
  ]);
24
26
  const ERROR_CODES = new Set([
25
27
  "SQLITE_BUSY", "SQLITE_LOCKED", "SQLITE_IOERR", "SQLITE_FULL", "SQLITE_READONLY",
@@ -101,6 +103,12 @@ export class ControllerDiagnosticJournal {
101
103
  error(error) {
102
104
  this.#enqueue("error", { error: errorEvidence(error) });
103
105
  }
106
+ /** Completion-driven evidence bypasses the periodic recent-array/sample
107
+ * window, but uses the same allowlist, queue/record/file bounds and async I/O.
108
+ * Disk failure, queue pressure and force-kill remain explicit evidence gaps. */
109
+ request(record) {
110
+ this.#enqueue("request", { request: safeSnapshot(record) });
111
+ }
104
112
  sample(snapshot, lagMs) {
105
113
  const now = monotonicMilliseconds();
106
114
  if (now - this.#lastSampleAt < 1000) {