@zq-silk/yui 2.17.0 → 2.19.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 (73) hide show
  1. package/dist/cli/commandCatalog.js +2 -1
  2. package/dist/commands/controllerCommands.js +14 -4
  3. package/dist/commands/durableJobCommands.js +50 -3
  4. package/dist/commands/globalRoleCommands.js +18 -12
  5. package/dist/commands/inboxCommands.js +1 -0
  6. package/dist/commands/operatorCommands.js +10 -2
  7. package/dist/controlPlaneCli.js +21 -4
  8. package/dist/controller/clientRuntime.js +34 -13
  9. package/dist/controller/controller.js +45 -22
  10. package/dist/controller/diagnosticJournal.js +214 -0
  11. package/dist/controller/fileSchedulerStoreAdapter.js +143 -101
  12. package/dist/controller/globalInputDelivery.js +6 -2
  13. package/dist/controller/globalRuntimeAttention.js +11 -3
  14. package/dist/controller/jobControl.js +1 -1
  15. package/dist/controller/jobDefinitionControl.js +24 -13
  16. package/dist/controller/jobSupervisor.js +4 -0
  17. package/dist/controller/messageHealth.js +354 -0
  18. package/dist/controller/runtime.js +75 -5
  19. package/dist/controller/taskHostTerminalRecovery.js +11 -0
  20. package/dist/controller/workerSchedulerStore.js +24 -0
  21. package/dist/core/boundedRpc.js +4 -0
  22. package/dist/core/controllerServer.js +1 -1
  23. package/dist/core/controllerTelemetry.js +92 -19
  24. package/dist/job/globalShell.js +51 -0
  25. package/dist/job/jobBusinessResult.js +4 -3
  26. package/dist/job/jobDefinition.js +6 -1
  27. package/dist/job/jobRunner.js +9 -0
  28. package/dist/job/scheduleService.js +2 -0
  29. package/dist/message/globalInboxRecovery.js +30 -19
  30. package/dist/message/globalProviderRetry.js +7 -1
  31. package/dist/runtime/builtinAgentErrorMappers.js +4 -0
  32. package/dist/runtime/globalSessionHealth.js +39 -0
  33. package/dist/scheduler/roleRunStall.js +10 -10
  34. package/dist/storage/contextRecords.js +16 -0
  35. package/dist/storage/persistenceWorker.js +41 -19
  36. package/dist/storage/sqliteSchema.js +35 -11
  37. package/dist/storage/sqliteStore.js +102 -8
  38. package/dist/storage/storageVersions.js +1 -1
  39. package/dist/storage/storeRpc.js +25 -11
  40. package/dist/storage/taskReadRevisionSchema.js +39 -0
  41. package/dist/web/assets/client/app/catalog.js +0 -29
  42. package/dist/web/assets/client/app/jobs.js +115 -0
  43. package/dist/web/assets/client/app/main.js +17 -6
  44. package/dist/web/assets/client/app/manage.js +1 -0
  45. package/dist/web/assets/client/app/settings.js +1 -5
  46. package/dist/web/assets/client/i18n/en.js +203 -18
  47. package/dist/web/assets/client/i18n/zh-CN.js +202 -17
  48. package/dist/web/assets/client/index.js +2 -0
  49. package/dist/web/assets/client/layout/manageShell.js +61 -15
  50. package/dist/web/assets/client/layout/router.js +2 -1
  51. package/dist/web/assets/client/lib/prefs.js +1 -1
  52. package/dist/web/assets/client/views/jobs.js +1042 -160
  53. package/dist/web/assets/client/views/sidebar.js +3 -0
  54. package/dist/web/assets/shared/manageNav.js +3 -1
  55. package/dist/web/assets/shell/appRail.js +2 -1
  56. package/dist/web/assets/shell/manage.js +7 -2
  57. package/dist/web/assets/shell/models.js +4 -3
  58. package/dist/web/assets/shell/settingsContent.js +0 -3
  59. package/dist/web/assets/shell/sidebar.js +0 -1
  60. package/dist/web/assets/styles/views/jobs.js +301 -12
  61. package/dist/web/assets/styles/views/sidebar.js +1 -2
  62. package/dist/web/webServer.js +28 -1
  63. package/docs/global-shell-jobs.md +78 -0
  64. package/docs/job-runs.md +6 -3
  65. package/docs/managed-turn-and-session-runtime.md +170 -0
  66. package/docs/managed-turn-and-session-runtime.zh-CN.md +116 -0
  67. package/docs/release-workflow.md +63 -0
  68. package/docs/release-workflow.zh-CN.md +44 -0
  69. package/docs/storage-baseline.md +14 -1
  70. package/docs/storage-baseline.zh-CN.md +6 -1
  71. package/docs/testing/verification-levels.md +2 -2
  72. package/docs/testing/verification-levels.zh-CN.md +2 -2
  73. package/package.json +1 -1
@@ -1696,7 +1696,8 @@ export const ROOT_COMMAND = buildNode({
1696
1696
  : name === "run-delegation" ? "Read the exact execution Task/WorkItem and attention projection; never a grant."
1697
1697
  : `${name} a stable Job or exact Run.`,
1698
1698
  usage: `yui job ${name} (--task <id>|--global)${name === "list" ? "" : " --job <id>"}`
1699
- + (name === "define" || name === "update" ? " --title <text> --input <configuration-json>" : "")
1699
+ + (name === "define" ? " --title <text> (--input <json>|--input-file <file>|--command <shell>) [--script <path=local-file> ...]" : "")
1700
+ + (name === "update" ? " [--title <text>] [--input <json>|--input-file <file>|--command <shell>] [--script <path=local-file> ...]" : "")
1700
1701
  + (name === "update" ? " --revision <current>" : "")
1701
1702
  + (name === "run" ? " --request-id <key> [--retry-of <run-id>]" : "")
1702
1703
  + (["run-get", "run-result", "run-evidence", "run-confirm", "run-delegation", "logs", "run-cancel", "run-acknowledge"].includes(name) ? " --run <run-id>" : "")
@@ -161,10 +161,9 @@ export function parseControllerRuntimeSnapshot(result, droppedEvents) {
161
161
  const drain = runtime.drain;
162
162
  const commands = runtime.commands;
163
163
  const routes = commands?.routes;
164
- const dispatcher = commands?.dispatcher;
165
164
  const eventLoopDelay = commands?.eventLoopDelay;
166
- const queueDepth = (typeof routes?.inFlight === "number" ? routes.inFlight : 0)
167
- + (typeof dispatcher?.inFlight === "number" ? dispatcher.inFlight : 0);
165
+ // Dispatcher work is a subset of authenticated routes, not another queue.
166
+ const queueDepth = typeof routes?.inFlight === "number" ? routes.inFlight : undefined;
168
167
  return {
169
168
  source: "controller.status",
170
169
  pid: typeof record.pid === "number" ? record.pid : undefined,
@@ -182,6 +181,17 @@ export function parseControllerRuntimeSnapshot(result, droppedEvents) {
182
181
  ? drain.progressEventsCoalesced
183
182
  : undefined,
184
183
  droppedEvents,
184
+ observations: {
185
+ commands: (runtime.commands ?? null),
186
+ phases: (runtime.phases ?? null),
187
+ reads: (runtime.reads ?? null),
188
+ persistence: (runtime.persistence ?? null),
189
+ diagnostics: (runtime.diagnostics ?? null),
190
+ messageHealth: (runtime.messageHealth ?? null),
191
+ scheduler: (runtime.scheduler ?? null),
192
+ preRoutingWait: "unmeasured; event-loop lag is sampled, not per-request queue time",
193
+ clock: "process-monotonic milliseconds; target/request labels are sha256 prefixes"
194
+ },
185
195
  queueDepth,
186
196
  oldestInFlightAgeMs: typeof routes?.oldestInFlightAgeMs === "number"
187
197
  ? routes.oldestInFlightAgeMs
@@ -210,7 +220,7 @@ export function renderRuntimeIdentitySection(input) {
210
220
  }
211
221
  lines.push(` Health ${health.status === "ok" ? "ok" : health.status === "degraded" ? "DEGRADED (needs-repair above)" : "FAIL (contradictions above)"}`, ` Controller ${runtime.source === "controller.status" && runtime.pid !== undefined
212
222
  ? `PID ${runtime.pid}${runtime.uptimeMs === undefined ? "" : `, up ${formatDuration(runtime.uptimeMs)}`}${runtime.protocolVersion === undefined ? "" : `, protocol ${runtime.protocolVersion}`}`
213
- : "not running (unsupported)"}`, ` Controller RSS ${runtime.rssBytes !== undefined ? formatBytes(runtime.rssBytes) : UNSUPPORTED} (inventory ${formatBytes(inventoryRssBytes)})`, ` Command queue depth ${runtime.queueDepth ?? UNSUPPORTED} · oldest ${runtime.oldestInFlightAgeMs === undefined
223
+ : "not running (unsupported)"}`, ` Controller RSS ${runtime.rssBytes !== undefined ? formatBytes(runtime.rssBytes) : UNSUPPORTED} (inventory ${formatBytes(inventoryRssBytes)})`, ` Commands in flight ${runtime.queueDepth ?? UNSUPPORTED} · oldest ${runtime.oldestInFlightAgeMs === undefined
214
224
  ? UNSUPPORTED
215
225
  : runtime.oldestInFlightAgeMs === null
216
226
  ? "—"
@@ -4,6 +4,7 @@ import { callFileTaskController } from "../controller/clientRuntime.js";
4
4
  import { acknowledgeDurableJob, cancelDurableJob, getDurableJob, startDurableJob } from "../controller/jobClient.js";
5
5
  import { resolveJobCaller, taskLocalActor } from "../task/taskAuthority.js";
6
6
  import { boundedJobArtifactPayload } from "../artifacts/jobArtifactTransport.js";
7
+ import { readFileSync, statSync } from "node:fs";
7
8
  /**
8
9
  * The textual `--owner` forms accepted by `job start`. The public help text in
9
10
  * the command catalog must document exactly these forms.
@@ -38,9 +39,25 @@ export async function runDurableJobCommand(args, options) {
38
39
  }
39
40
  async function definitionCommand(command, args, options) {
40
41
  const flags = new Map();
42
+ const scripts = [];
43
+ const readText = (path) => {
44
+ if (!statSync(path).isFile() || statSync(path).size > 512 * 1024)
45
+ throw usageError("Job input files must be text files up to 512 KiB.");
46
+ return readFileSync(path, "utf8");
47
+ };
41
48
  for (let i = 0; i < args.length; i++) {
42
49
  const flag = args[i];
43
- if (!["--global", "--task", "--job", "--title", "--input", "--revision", "--request-id", "--retry-of", "--run", "--artifact", "--reference", "--offset", "--result", "--expected-session", "--expected-attempt"].includes(flag)
50
+ if (flag === "--script") {
51
+ const value = args[++i] ?? "", separator = value.indexOf("=");
52
+ if (separator < 1 || separator === value.length - 1)
53
+ throw usageError("--script requires relative-path=local-file.");
54
+ const path = value.slice(0, separator);
55
+ if (scripts.some(file => file.path === path))
56
+ throw usageError("Repeated script path.");
57
+ scripts.push({ path, content: readText(value.slice(separator + 1)) });
58
+ continue;
59
+ }
60
+ if (!["--global", "--task", "--job", "--title", "--input", "--input-file", "--command", "--revision", "--request-id", "--retry-of", "--run", "--artifact", "--reference", "--offset", "--result", "--expected-session", "--expected-attempt"].includes(flag)
44
61
  || flags.has(flag))
45
62
  throw usageError(`Unknown or repeated Job flag: ${flag}.`);
46
63
  if (flag === "--global") {
@@ -61,14 +78,44 @@ async function definitionCommand(command, args, options) {
61
78
  ?? (executionRead ? options.environment?.YUI_TASK_ID : undefined) ?? "");
62
79
  const scope = taskId === undefined ? { kind: "global" } : { kind: "task", taskId };
63
80
  let configuration;
64
- if (flags.has("--input")) {
81
+ if (flags.has("--input") && flags.has("--input-file"))
82
+ throw usageError("Choose --input or --input-file.");
83
+ if (flags.has("--input") || flags.has("--input-file")) {
65
84
  try {
66
- configuration = JSON.parse(flags.get("--input"));
85
+ configuration = JSON.parse(flags.has("--input-file") ? readText(flags.get("--input-file")) : flags.get("--input"));
67
86
  }
68
87
  catch {
69
88
  throw usageError("--input must be a JSON Job configuration.");
70
89
  }
71
90
  }
91
+ if ((flags.has("--command") || scripts.length || flags.has("--input-file")) && !["define", "update"].includes(command)) {
92
+ throw usageError("Command and script edits require job define or update.");
93
+ }
94
+ if (flags.has("--command") || scripts.length) {
95
+ if (taskId !== undefined || configuration !== undefined)
96
+ throw usageError("--command/--script are global Shell shortcuts; use --input for a complete configuration.");
97
+ let shell = { kind: "shell", env: {}, steps: [], files: [] };
98
+ if (command === "update") {
99
+ const existing = await callFileTaskController(options.home, "job.definition", {
100
+ op: "show", scope, caller, id: flags.get("--job")
101
+ }, { environment: options.environment });
102
+ if (existing.revision !== Number(flags.get("--revision")))
103
+ throw usageError("Job revision changed; inspect before editing.");
104
+ if (existing.configuration.kind !== "shell")
105
+ throw usageError("Changing executor requires a new Job.");
106
+ shell = existing.configuration;
107
+ if (!flags.has("--title"))
108
+ flags.set("--title", existing.title);
109
+ }
110
+ if (flags.has("--command")) {
111
+ if (shell.steps.length > 1)
112
+ throw usageError("Use --input-file to edit a multi-step Job without discarding steps.");
113
+ shell = { ...shell, steps: [{ ...shell.steps[0], name: shell.steps[0]?.name ?? "run", command: flags.get("--command") }] };
114
+ // The command shortcut intentionally selects shell execution, not stale argv.
115
+ delete shell.steps[0].argv;
116
+ }
117
+ configuration = { ...shell, files: [...shell.files.filter(file => !scripts.some(next => next.path === file.path)), ...scripts] };
118
+ }
72
119
  const requestId = flags.get("--request-id");
73
120
  const parseObject = (flag) => {
74
121
  if (!flags.has(flag))
@@ -2,7 +2,8 @@ import { roleNotFound, usageError } from "../errors/cliError.js";
2
2
  import { hasManagedIdentity } from "../runtime/managedIdentity.js";
3
3
  import { requireManagedGlobalCaller } from "../runtime/managedCaller.js";
4
4
  import { boundedDocument, recordPage, readOptions, readDigest, messageReceipt, summaryText, summaryMetadata } from "../output/boundedRead.js";
5
- import { controlProviderRetry, providerRetryProjection, providerRetrySummary, renderProviderRetry } from "../runtime/providerRetry.js";
5
+ import { controlProviderRetry, providerRetryProjection, renderProviderRetry } from "../runtime/providerRetry.js";
6
+ import { globalSessionHealth } from "../runtime/globalSessionHealth.js";
6
7
  import { pendingGlobalInboxMessages, inboxHandlingState, globalInboxDeliveryState } from "../message/inboxReceipt.js";
7
8
  import { settleGlobalRetryInput } from "../message/globalProviderRetry.js";
8
9
  import { globalInputBacklog } from "../message/globalInputBacklog.js";
@@ -134,6 +135,7 @@ function roleContext(args, store, options) {
134
135
  const effective = resolveEffectiveLaunch({ role, purpose: "execution" });
135
136
  const sessions = store.getGlobalRoleSessionSet(name);
136
137
  const session = sessions?.sessions[role.activeAgentId];
138
+ const health = globalSessionHealth(role, sessions);
137
139
  const currentSkills = checkCurrentSkills(options.yuiHome ?? environment.YUI_HOME, roleSkillIds(session?.effective.context ?? role, role.name === "operator" ? "operator" : "global"));
138
140
  // Native delivery and explicit Agent handling settle the same queued input
139
141
  // through distinct receipts; a context read does neither.
@@ -164,7 +166,9 @@ function roleContext(args, store, options) {
164
166
  agentId: binding.agentId,
165
167
  adapterId: binding.adapterId,
166
168
  workspace: role.workspace,
167
- effectiveRevision: role.launchRevision,
169
+ desiredRevision: health.desiredRevision,
170
+ effectiveRevision: health.effectiveRevision,
171
+ configurationDrift: health.configurationDrift,
168
172
  ...(session?.nativeSessionId === undefined
169
173
  ? {}
170
174
  : { nativeSessionId: session.nativeSessionId })
@@ -180,7 +184,7 @@ function roleContext(args, store, options) {
180
184
  authority: {
181
185
  view: role.name === "operator" ? "operator" : "global",
182
186
  taskImplementation: false,
183
- writableProjectIds: effective.writeProjectIds,
187
+ writableProjectIds: (session?.effective ?? effective).writeProjectIds,
184
188
  allowedActions: role.name === "operator"
185
189
  ? ["route-request", "inspect-catalog", "answer-user-boundary"]
186
190
  : ["perform-global-role-request"]
@@ -196,17 +200,14 @@ function roleContext(args, store, options) {
196
200
  diagnostics: `role message list ${name} --diagnostics`,
197
201
  retry: `session retry ${name} show`
198
202
  },
199
- nativeTurn: sessions?.providerBinding?.run == null ? null : {
200
- attemptId: sessions.providerBinding.run.attemptId,
201
- nativeTurnId: sessions.providerBinding.run.nativeTurnId,
202
- status: sessions.providerBinding.run.status
203
- },
203
+ nativeTurn: health.nativeTurn,
204
+ health,
204
205
  ...(role.jobOwner === undefined ? {} : { job: {
205
206
  owner: role.jobOwner,
206
207
  read: `yui job show ${role.jobOwner.scope.kind === "global" ? "--global" : `--task ${role.jobOwner.scope.taskId}`} --job ${role.jobOwner.jobId} --json`,
207
208
  boundary: "Job execution only. This Role is not Operator and owns no Task delivery authority. Read the exact Run from its original message."
208
209
  } }),
209
- retry: providerRetrySummary(sessions?.providerBinding),
210
+ retry: health.retry,
210
211
  // Historical interrupt receipts are available through Session inspection;
211
212
  // only this Turn's exact identity is required at entry.
212
213
  sessionManifestPath: environment.YUI_SESSION_MANIFEST,
@@ -510,7 +511,7 @@ function roleSession(args, store, options) {
510
511
  throw usageError("Usage: yui session retry <role> [show|cancel|disable|enable]");
511
512
  }
512
513
  return store.transaction(tx => {
513
- requireRole(name, tx);
514
+ const role = requireRole(name, tx);
514
515
  const sessions = tx.getGlobalRoleSessionSet(name);
515
516
  let binding = sessions?.providerBinding ?? null;
516
517
  if (action !== "show") {
@@ -520,9 +521,14 @@ function roleSession(args, store, options) {
520
521
  tx.saveGlobalRoleSessionSet({ ...sessions, providerBinding: binding });
521
522
  settleGlobalRetryInput(tx, name, binding, new Date());
522
523
  }
524
+ const health = globalSessionHealth(role, sessions);
525
+ const historical = binding?.retry !== undefined && health.retry === null;
523
526
  return options.jsonOutput ? JSON.stringify({
524
- roleName: name, retry: providerRetryProjection(binding), disabled: binding?.retryDisabled ?? false
525
- }) + "\n" : renderProviderRetry(binding) + "\n";
527
+ roleName: name, retry: providerRetryProjection(binding), disabled: binding?.retryDisabled ?? false,
528
+ selectedNativeSessionId: health.nativeSessionId,
529
+ retryScope: historical ? "historical-session" : "current-session"
530
+ }) + "\n" : (historical ? "Historical Session evidence (not the selected Session's current failure): " : "")
531
+ + renderProviderRetry(binding) + "\n";
526
532
  });
527
533
  }
528
534
  if (command !== "record" && command !== "replace") {
@@ -322,6 +322,7 @@ export function runInboxCommand(args, store, env, now = new Date()) {
322
322
  if (message.inbox?.ack || message.delivery !== undefined || message.interruptThen !== undefined
323
323
  || message.control !== undefined && message.control.outcome !== "rejected"
324
324
  || turn?.attemptId === `global-input:${current.roleName}/${message.id}`
325
+ && turn.status !== "rejected"
325
326
  || !foreignTarget && message.notDelivered === undefined) {
326
327
  throw new Error("Retirement requires obsolete, proven-unsubmitted input; unknown/accepted/interrupt-owned input must retain its exact handling path.");
327
328
  }
@@ -1,5 +1,6 @@
1
1
  import { agentAdapterLabel as adapterLabel } from "../agent/adapterCatalog.js";
2
2
  import { assertRoleEnabled } from "../role/availability.js";
3
+ import { globalSessionHealth } from "../runtime/globalSessionHealth.js";
3
4
  import { usageError } from "../errors/cliError.js";
4
5
  import { createRoleSessionSet } from "../executor/agentExecutor.js";
5
6
  import { TASK_SUBMISSION_INTENTS } from "../message/message.js";
@@ -32,7 +33,11 @@ function status(args, store, options) {
32
33
  if (activeBinding === undefined) {
33
34
  throw usageError(`Operator active Agent is not bound: ${role.activeAgentId}.`);
34
35
  }
35
- const projection = projectOperatorStatus(store.getGlobalRoleSessionSet(role.name), role.activeAgentId, activeBinding.adapterId);
36
+ const sessions = store.getGlobalRoleSessionSet(role.name);
37
+ const projection = {
38
+ ...projectOperatorStatus(sessions, role.activeAgentId, activeBinding.adapterId),
39
+ health: globalSessionHealth(role, sessions)
40
+ };
36
41
  const writer = projection.writer;
37
42
  const writerLines = [
38
43
  "Operator status",
@@ -42,7 +47,10 @@ function status(args, store, options) {
42
47
  ` Adapter: ${adapterLabel(writer.adapterId)}`,
43
48
  ` Conversation: ${writer.sessionRef ?? "-"}`,
44
49
  ` Native session: ${writer.nativeSessionId ?? "-"}`,
45
- ` Session state: ${writer.sessionStatus ?? "-"}`
50
+ ` Session state: ${writer.sessionStatus ?? "-"}`,
51
+ ` Configuration: desired r${projection.health.desiredRevision}, effective r${projection.health.effectiveRevision ?? "unknown"}`,
52
+ ` Host observation: ${projection.health.hostObservation}`,
53
+ ` ${projection.health.note}`
46
54
  ];
47
55
  const history = projection.historicalConversations;
48
56
  const historyOutput = history.length === 0
@@ -532,8 +532,9 @@ export async function main() {
532
532
  const result = await callController(home, "controller.status", {}, { timeoutMs: 2_000 });
533
533
  runtime = parseControllerRuntimeSnapshot(result, droppedEvents);
534
534
  }
535
- catch {
536
- runtime = { source: "unsupported", droppedEvents };
535
+ catch (error) {
536
+ runtime = { source: "unsupported", droppedEvents,
537
+ error: describeCliFailure(error, failureContext()).message.slice(0, 1000) };
537
538
  }
538
539
  const mismatch = summarizeDurablePhysicalMismatch(snapshot);
539
540
  const identitySection = renderRuntimeIdentitySection({
@@ -2611,6 +2612,7 @@ async function executeOperatorSessionControl(control, home, store, runtime, tmux
2611
2612
  }
2612
2613
  await prepareInteractiveCodexApp(control.targetAgentId, store, home);
2613
2614
  await ensureFileTaskController(home, { environment: process.env });
2615
+ let stopDisposition = "not-required";
2614
2616
  if (paneRunning
2615
2617
  || (active !== undefined
2616
2618
  && active.status === "active")
@@ -2618,9 +2620,24 @@ async function executeOperatorSessionControl(control, home, store, runtime, tmux
2618
2620
  && ["submitting", "accepted", "delivery-unknown"].includes(sessionSet.providerBinding.run.status))
2619
2621
  || (sessionSet?.providerBinding?.goal != null && sessionSet.providerBinding.goal.status !== "complete")) {
2620
2622
  await runtime.stopGlobalRoleSession(role.name);
2623
+ stopDisposition = "completed";
2624
+ }
2625
+ try {
2626
+ applyOperatorSessionControl(control, store);
2627
+ }
2628
+ catch (error) {
2629
+ throw new Error(`Operator replacement selection failed; prior stop=${stopDisposition}; `
2630
+ + "new Session startup has not begun. Inspect the retained Session state.", { cause: error });
2631
+ }
2632
+ try {
2633
+ await runtime.prepareGlobalRoleEnter(role.name);
2634
+ }
2635
+ catch (error) {
2636
+ throw new Error(`Operator replacement selection committed; prior stop=${stopDisposition}; its startup phase failed `
2637
+ + "without readiness confirmation. Whether native startup began is unconfirmed. "
2638
+ + "Inspect the exact current Session/Host before retrying; "
2639
+ + "do not infer that startup had no effect.", { cause: error });
2621
2640
  }
2622
- applyOperatorSessionControl(control, store);
2623
- await runtime.prepareGlobalRoleEnter(role.name);
2624
2641
  tmux.attachRole("operator", role.name, "auto");
2625
2642
  }
2626
2643
  async function prepareInteractiveCodexApp(agentId, store, home) {
@@ -456,10 +456,7 @@ export class FileTaskWorkflowRuntime {
456
456
  }
457
457
  if (targets.length === 0)
458
458
  return;
459
- await callFileTaskController(this.home, "scheduler.scan", {}, {
460
- ...this.clientOptions,
461
- requestTimeoutMs: LIFECYCLE_REQUEST_TIMEOUT_MS
462
- });
459
+ await Promise.all(targets.map(target => this.#waitForRuntimeCleanup(target)));
463
460
  for (const target of targets) {
464
461
  if (hasRuntimeLifecycleWork(this.store.getWorkMailbox(target))) {
465
462
  throw new Error(`Role runtime did not stop: ${target.taskId}/${target.roleName}.`);
@@ -487,10 +484,7 @@ export class FileTaskWorkflowRuntime {
487
484
  if (target === null) {
488
485
  throw new Error(`Role Session changed before its exact stop was reserved: ${input.taskId}/${input.roleName}.`);
489
486
  }
490
- await callFileTaskController(this.home, "scheduler.scan", {}, {
491
- ...this.clientOptions,
492
- requestTimeoutMs: LIFECYCLE_REQUEST_TIMEOUT_MS
493
- });
487
+ await this.#waitForRuntimeCleanup(target);
494
488
  if (hasRuntimeLifecycleWork(this.store.getWorkMailbox(target))) {
495
489
  throw new Error(`Role runtime did not stop: ${input.taskId}/${input.roleName}.`);
496
490
  }
@@ -506,7 +500,7 @@ export class FileTaskWorkflowRuntime {
506
500
  .filter((job) => job.taskId === taskId);
507
501
  if (active.length === 0)
508
502
  return;
509
- await callFileTaskController(this.home, "scheduler.scan", {}, {
503
+ await callFileTaskController(this.home, "scheduler.signal", { key: `task:${encodeURIComponent(taskId)}` }, {
510
504
  ...this.clientOptions,
511
505
  requestTimeoutMs: LIFECYCLE_REQUEST_TIMEOUT_MS
512
506
  });
@@ -585,10 +579,7 @@ export class FileTaskWorkflowRuntime {
585
579
  if (target?.kind !== "global-role-runtime") {
586
580
  throw new Error(`Global Role runtime cleanup target is invalid: ${roleName}.`);
587
581
  }
588
- await callFileTaskController(this.home, "scheduler.scan", {}, {
589
- ...this.clientOptions,
590
- requestTimeoutMs: LIFECYCLE_REQUEST_TIMEOUT_MS
591
- });
582
+ await this.#waitForRuntimeCleanup(target);
592
583
  if (hasRuntimeLifecycleWork(this.store.getWorkMailbox(target))) {
593
584
  throw new Error(`Global Role runtime did not stop: ${roleName}.`);
594
585
  }
@@ -598,6 +589,36 @@ export class FileTaskWorkflowRuntime {
598
589
  throw new Error(`Global Role runtime session is still active: ${roleName}.`);
599
590
  }
600
591
  }
592
+ /**
593
+ * The durable cleanup mailbox is completed only after stopOwner proves
594
+ * quiescence and commits the existing cleanup transaction. A scheduler hint
595
+ * is not that proof; nor is exhaustion of the Home-wide coalescing loop.
596
+ */
597
+ async #waitForRuntimeCleanup(target) {
598
+ const timeoutMs = this.clientOptions.requestTimeoutMs ?? LIFECYCLE_REQUEST_TIMEOUT_MS;
599
+ const deadline = Date.now() + timeoutMs;
600
+ const label = controllerMailboxKey(target);
601
+ try {
602
+ await callFileTaskController(this.home, "scheduler.signal", { key: label }, {
603
+ ...this.clientOptions, requestTimeoutMs: timeoutMs
604
+ });
605
+ while (hasRuntimeLifecycleWork(this.store.getWorkMailbox(target))) {
606
+ if (Date.now() >= deadline) {
607
+ throw new Error("Timed out awaiting the target's durable cleanup transaction.");
608
+ }
609
+ await new Promise(resolve => setTimeout(resolve, Math.min(this.clientOptions.pollIntervalMs ?? POLL_INTERVAL_MS, Math.max(1, deadline - Date.now()))));
610
+ }
611
+ }
612
+ catch (error) {
613
+ // A lost hint response must not hide cleanup that actually committed.
614
+ // No hint or native input is replayed here.
615
+ if (!hasRuntimeLifecycleWork(this.store.getWorkMailbox(target)))
616
+ return;
617
+ throw new Error(`Runtime stop remains unconfirmed for ${label}; cleanup obligation retained. `
618
+ + "No replacement was started by this stop operation. Inspect the exact Session/Host "
619
+ + "and cleanup failure before retrying; no physical exit is inferred.", { cause: error });
620
+ }
621
+ }
601
622
  /** Drain pending runtime facts while an external maintenance fence is held. */
602
623
  async drainController() {
603
624
  await ensureFileTaskController(this.home, {
@@ -7,7 +7,7 @@ import { reconcileExitedRoleRuns } from "../scheduler/roleRunLiveness.js";
7
7
  import { DEFAULT_STALL_WINDOW_MS, DEFAULT_WORKFLOW_STALL_CANDIDATE_AGE_MS, reconcileStalledRoleRuns } from "../scheduler/roleRunStall.js";
8
8
  import { processOperatorInputNotifications } from "../scheduler/operatorInputNotificationProcessor.js";
9
9
  import { startControllerServer } from "../core/controllerServer.js";
10
- import { monotonicMilliseconds } from "../core/controllerTelemetry.js";
10
+ import { ControllerWorkObserver, monotonicMilliseconds } from "../core/controllerTelemetry.js";
11
11
  import { isProjectMaintenanceFenced } from "../repository/projectMaintenanceLock.js";
12
12
  import { isHandoverLockHeld } from "../release/runtimeRelease.js";
13
13
  import { KeyedWorkQueue } from "../coordination/keyedWorkQueue.js";
@@ -44,7 +44,9 @@ const ZERO_DRAIN_METRICS = Object.freeze({
44
44
  * Runs one lean scheduler pass. Due native Turn completions are folded before
45
45
  * liveness, so a valid Hook boundary fences destructive process reconciliation.
46
46
  */
47
- export async function runControllerSchedulerPass(store, delivery, now, workspacePreparer, scope = { kind: "full" }, includeOperator = true, runtimeCleanupOutcomes = [], lifecycleHost, stallWindowMs = DEFAULT_STALL_WINDOW_MS, maintenanceFence, onMaintenanceFenceDefer, blockedTaskIds = new Set(), diagnosticAfterMs = DEFAULT_WORKFLOW_STALL_CANDIDATE_AGE_MS, leaderWakeFence, onError) {
47
+ export async function runControllerSchedulerPass(store, delivery, now, workspacePreparer, scope = { kind: "full" }, includeOperator = true, runtimeCleanupOutcomes = [], lifecycleHost, stallWindowMs = DEFAULT_STALL_WINDOW_MS, maintenanceFence, onMaintenanceFenceDefer, blockedTaskIds = new Set(), diagnosticAfterMs = DEFAULT_WORKFLOW_STALL_CANDIDATE_AGE_MS, leaderWakeFence, onError, observer) {
48
+ const target = scope.kind === "full" ? "home" : scope.keys.slice(0, 16).join(",");
49
+ const observe = (phase, action) => observer === undefined ? Promise.resolve().then(action) : observer.observe(phase, target, action);
48
50
  const compiledSelection = compileReconcileSelection(scope);
49
51
  const selection = includeOperator
50
52
  ? { ...compiledSelection, blockedTaskIds }
@@ -54,7 +56,7 @@ export async function runControllerSchedulerPass(store, delivery, now, workspace
54
56
  // event-loop turn. Give already-written requests a poll boundary before the
55
57
  // next durable phase; later phases retain their existing CAS fences.
56
58
  await controlEventLoopRun();
57
- const failedCleanupRoles = await processSelectedRoleRuntimeCleanups(store, delivery, lifecycleHost, scope, now, runtimeCleanupOutcomes, blockedTaskIds);
59
+ const failedCleanupRoles = await observe("runtime-cleanup", () => processSelectedRoleRuntimeCleanups(store, delivery, lifecycleHost, scope, now, runtimeCleanupOutcomes, blockedTaskIds));
58
60
  const roleSelection = selectionWithoutFailedCleanupRoles(store, selection, failedCleanupRoles);
59
61
  const availableWakeupSelection = () => (leaderWakeFence?.() === true
60
62
  ? exactTaskSelection(new Set())
@@ -64,19 +66,19 @@ export async function runControllerSchedulerPass(store, delivery, now, workspace
64
66
  // Durable Leader work that already has a ready Task workspace belongs to
65
67
  // the control path. Dispatch it before any unrelated Task workspace I/O;
66
68
  // the processor itself retains the fail-closed workspace-ready guard.
67
- const initialWakeups = selectedPendingWakeups(store, availableWakeupSelection());
68
- const initialWakeupResults = await processLeaderWakeups(store, delivery, now, exactTaskSelection(new Set(initialWakeups.keys())));
69
+ const initialWakeups = await observe("wakeup-projection", () => selectedPendingWakeups(store, availableWakeupSelection()));
70
+ const initialWakeupResults = await observe("leader-delivery", () => processLeaderWakeups(store, delivery, now, exactTaskSelection(new Set(initialWakeups.keys()))));
69
71
  // Preserve ready-Leader-first ordering, then bound the repeated state
70
72
  // projections that follow it in this pass.
71
73
  await controlEventLoopRun();
72
74
  // A Task whose deferred activation was just released must become active
73
75
  // before the workspace phase, so its first workspace preparation happens in
74
76
  // this same pass rather than waiting for the next one.
75
- await adoptReleasedTaskActivations(store, workspacePreparer, selection, onError);
77
+ await observe("activation", () => adoptReleasedTaskActivations(store, workspacePreparer, selection, onError));
76
78
  await controlEventLoopRun();
77
- const workspacePreparation = await prepareActiveWorkspaces(store, workspacePreparer, selection, maintenanceFence, onMaintenanceFenceDefer);
79
+ const workspacePreparation = await observe("workspace-preparation", () => prepareActiveWorkspaces(store, workspacePreparer, selection, maintenanceFence, onMaintenanceFenceDefer));
78
80
  await controlEventLoopRun();
79
- const activeRunDeliveries = await processActiveRoleRunDeliveries(store, delivery, now, roleSelection);
81
+ const activeRunDeliveries = await observe("run-delivery", () => processActiveRoleRunDeliveries(store, delivery, now, roleSelection));
80
82
  await controlEventLoopRun();
81
83
  const unsettledRunRefs = new Set(activeRunDeliveries.flatMap((result) => (result.reason === "delivery-uncertain"
82
84
  ? [formatTaskRecordReference(result.taskId, result.runId, "run")]
@@ -108,16 +110,16 @@ export async function runControllerSchedulerPass(store, delivery, now, workspace
108
110
  await controlEventLoopRun();
109
111
  const liveStatuses = new Map();
110
112
  const resourceEvidence = new Map();
111
- const failedRunRefs = await reconcileExitedRoleRuns(store, delivery, now, roleSelection, unsettledRunRefs, liveStatuses, resourceEvidence, scope.kind === "dirty");
113
+ const failedRunRefs = await observe("run-liveness", () => reconcileExitedRoleRuns(store, delivery, now, roleSelection, unsettledRunRefs, liveStatuses, resourceEvidence, scope.kind === "dirty"));
112
114
  await controlEventLoopRun();
113
115
  await reconcileStalledRoleRuns(store, delivery, now, roleSelection, stallWindowMs, liveStatuses, resourceEvidence, diagnosticAfterMs);
114
116
  await controlEventLoopRun();
115
117
  const selectedInputTaskIds = selectedTaskIdsForBoundedPass(store, selection);
116
118
  const autoResolvedInputs = selectedInputTaskIds === undefined
117
- ? store.resolveExpiredInputRecommendations(now)
119
+ ? await store.resolveExpiredInputRecommendations(now)
118
120
  : selectedInputTaskIds.size === 0
119
121
  ? []
120
- : store.resolveExpiredInputRecommendations(now, selectedInputTaskIds);
122
+ : await store.resolveExpiredInputRecommendations(now, selectedInputTaskIds);
121
123
  // Liveness and auto-resolution can durably queue new Leader work. Process
122
124
  // only Tasks that were not part of phase one, except when a same-pass due
123
125
  // completion made an initially busy Leader idle. Result order remains
@@ -691,10 +693,15 @@ export class FileTaskController {
691
693
  #jobSupervisor;
692
694
  #schedules;
693
695
  #globalInputDelivery;
696
+ #messageHealth;
697
+ #lastMessageHealth = null;
694
698
  #providerRetry;
695
699
  #continuationReconciler;
696
700
  #hostTerminalRecovery;
701
+ #persistenceMetrics;
702
+ #diagnosticMetrics;
697
703
  #current;
704
+ #phases = new ControllerWorkObserver();
698
705
  #operatorCurrent;
699
706
  #runtimeObserverCurrent;
700
707
  #pendingFull = false;
@@ -712,6 +719,8 @@ export class FileTaskController {
712
719
  constructor(store, delivery, options = {}) {
713
720
  this.store = store;
714
721
  this.delivery = delivery;
722
+ this.#persistenceMetrics = options.persistenceMetrics;
723
+ this.#diagnosticMetrics = options.diagnosticMetrics;
715
724
  this.#intervalMs = positiveInteger(options.intervalMs, DEFAULT_RECONCILIATION_INTERVAL_MS, "Controller reconciliation interval");
716
725
  this.#now = options.now ?? (() => new Date());
717
726
  this.#onError = options.onError ?? (() => { });
@@ -746,6 +755,7 @@ export class FileTaskController {
746
755
  this.#continuationReconciler = options.continuationReconciler;
747
756
  this.#hostTerminalRecovery = options.hostTerminalRecovery;
748
757
  this.#globalInputDelivery = options.globalInputDelivery;
758
+ this.#messageHealth = options.messageHealth;
749
759
  this.#providerRetry = options.providerRetry;
750
760
  this.#signalScheduler = new MailboxScheduler(async (keys) => { await this.#requestPass({ kind: "dirty", keys }); }, {
751
761
  windowMs: options.signalWindowMs ?? DEFAULT_SIGNAL_WINDOW_MS,
@@ -782,6 +792,13 @@ export class FileTaskController {
782
792
  runtimeMetrics() {
783
793
  const metrics = this.#lastRuntimeDrain?.metrics ?? ZERO_DRAIN_METRICS;
784
794
  return {
795
+ messageHealth: this.#lastMessageHealth,
796
+ diagnostics: this.#diagnosticMetrics?.() ?? null,
797
+ phases: this.#phases.snapshot(),
798
+ persistence: this.#persistenceMetrics?.() ?? null,
799
+ reads: this.store.readObservationMetrics?.() ?? null,
800
+ scheduler: { running: this.#current !== undefined, pendingFull: this.#pendingFull,
801
+ pendingKeys: this.#pendingKeys.size },
785
802
  // Status is intentionally O(1): scanning the inbox while serving the
786
803
  // control socket would recreate the starvation this metric diagnoses.
787
804
  inbox: {
@@ -956,7 +973,7 @@ export class FileTaskController {
956
973
  * without bypassing the scheduler's own ownership and delivery fences. */
957
974
  async #observeMaintenance(phase, action) {
958
975
  try {
959
- return await action();
976
+ return await this.#phases.observe(phase, "home", action);
960
977
  }
961
978
  catch (error) {
962
979
  this.#onError(new Error(`${phase} failed: ${error instanceof Error ? error.message : String(error)}. `
@@ -998,12 +1015,18 @@ export class FileTaskController {
998
1015
  }
999
1016
  }
1000
1017
  }
1001
- const firstRuntimeDrain = await this.#drainRuntimeEvents();
1018
+ const firstRuntimeDrain = await this.#phases.observe("runtime-drain", "home", () => this.#drainRuntimeEvents());
1002
1019
  if (scope.kind === "full" && this.#hostTerminalRecovery !== undefined) {
1003
1020
  await this.#observeMaintenance("Host terminal recovery", this.#hostTerminalRecovery);
1004
1021
  }
1005
- await this.#providerRetry?.reconcile();
1006
- await this.#globalInputDelivery?.({ reconcile: scope.kind === "full", now: this.#now() });
1022
+ if (scope.kind === "full" && this.#messageHealth !== undefined) {
1023
+ // Consume already collected Host evidence, never another live probe.
1024
+ const health = await this.#observeMaintenance("message-health", () => this.#messageHealth(this.#now()));
1025
+ if (health !== undefined)
1026
+ this.#lastMessageHealth = health;
1027
+ }
1028
+ await this.#phases.observe("provider-retry", "home", () => this.#providerRetry?.reconcile());
1029
+ await this.#phases.observe("global-input", "home", () => this.#globalInputDelivery?.({ reconcile: scope.kind === "full", now: this.#now() }));
1007
1030
  for (const taskId of runtimeTaskFailureIds(firstRuntimeDrain)) {
1008
1031
  runtimeFailedTaskIds.add(taskId);
1009
1032
  }
@@ -1016,17 +1039,17 @@ export class FileTaskController {
1016
1039
  // DurableJob reconciliation runs before the scheduler pass so a
1017
1040
  // terminal job's Leader wakeup is enqueued in the same pass that
1018
1041
  // processes Leader wakeups.
1019
- this.#schedules?.reconcile(this.#now());
1042
+ await this.#schedules?.reconcile(this.#now());
1020
1043
  this.#jobSupervisor?.reconcile(this.#now());
1021
1044
  if (scope.kind === "full") {
1022
- result = await runControllerSchedulerPass(this.store, this.delivery, this.#now(), this.#workspacePreparer, scope, false, runtimeCleanupOutcomes, this.#lifecycleHost, this.#stallWindowMs, this.#maintenanceFence, this.#onMaintenanceFenceDefer, runtimeFailedTaskIds, this.#diagnosticAfterMs, this.#leaderWakeFence, this.#onError);
1045
+ result = await this.#phases.observe("scheduler-full", "home", () => runControllerSchedulerPass(this.store, this.delivery, this.#now(), this.#workspacePreparer, scope, false, runtimeCleanupOutcomes, this.#lifecycleHost, this.#stallWindowMs, this.#maintenanceFence, this.#onMaintenanceFenceDefer, runtimeFailedTaskIds, this.#diagnosticAfterMs, this.#leaderWakeFence, this.#onError, this.#phases));
1023
1046
  }
1024
1047
  else {
1025
- const dirtyPass = await this.#runDirtySchedulerPass(scope, runtimeCleanupOutcomes, runtimeFailedTaskIds);
1048
+ const dirtyPass = await this.#phases.observe("scheduler-dirty", scope.keys.slice(0, 16).join(","), () => this.#runDirtySchedulerPass(scope, runtimeCleanupOutcomes, runtimeFailedTaskIds));
1026
1049
  result = dirtyPass.result;
1027
1050
  failedTaskScopes = dirtyPass.failedTaskScopes;
1028
1051
  }
1029
- const secondRuntimeDrain = await this.#drainRuntimeEvents();
1052
+ const secondRuntimeDrain = await this.#phases.observe("runtime-drain", "home", () => this.#drainRuntimeEvents());
1030
1053
  for (const taskId of runtimeTaskFailureIds(secondRuntimeDrain)) {
1031
1054
  runtimeFailedTaskIds.add(taskId);
1032
1055
  }
@@ -1040,7 +1063,7 @@ export class FileTaskController {
1040
1063
  // scheduler.
1041
1064
  if (scope.kind === "full" && this.#resourceAutoGc !== undefined) {
1042
1065
  try {
1043
- const gc = await this.#resourceAutoGc();
1066
+ const gc = await this.#phases.observe("resource-gc", "home", this.#resourceAutoGc);
1044
1067
  if (!gc.skipped && gc.failed > 0) {
1045
1068
  this.#onError(new Error(`Resource auto-GC failed to quarantine ${gc.failed} `
1046
1069
  + "resource(s); they stay in place and retry next pass."));
@@ -1120,7 +1143,7 @@ export class FileTaskController {
1120
1143
  const taskScopes = partition.taskScopes.filter((taskScope) => (!blockedTaskIds.has(taskScope.taskId)));
1121
1144
  const orderedResults = [];
1122
1145
  if (partition.globalKeys.length > 0) {
1123
- orderedResults.push(await runControllerSchedulerPass(this.store, this.delivery, this.#now(), this.#workspacePreparer, { kind: "dirty", keys: partition.globalKeys }, false, runtimeCleanupOutcomes, this.#lifecycleHost, this.#stallWindowMs, this.#maintenanceFence, this.#onMaintenanceFenceDefer, blockedTaskIds, this.#diagnosticAfterMs, this.#leaderWakeFence, this.#onError));
1146
+ orderedResults.push(await runControllerSchedulerPass(this.store, this.delivery, this.#now(), this.#workspacePreparer, { kind: "dirty", keys: partition.globalKeys }, false, runtimeCleanupOutcomes, this.#lifecycleHost, this.#stallWindowMs, this.#maintenanceFence, this.#onMaintenanceFenceDefer, blockedTaskIds, this.#diagnosticAfterMs, this.#leaderWakeFence, this.#onError, this.#phases));
1124
1147
  }
1125
1148
  if (taskScopes.length === 0
1126
1149
  || this.#stopped
@@ -1153,7 +1176,7 @@ export class FileTaskController {
1153
1176
  if (this.#stopped || this.#pendingFull)
1154
1177
  continue;
1155
1178
  try {
1156
- taskResults[selected.index] = await runControllerSchedulerPass(this.store, this.delivery, this.#now(), this.#workspacePreparer, { kind: "dirty", keys: selected.taskScope.keys }, false, taskCleanupOutcomes[selected.index], this.#lifecycleHost, this.#stallWindowMs, this.#maintenanceFence, this.#onMaintenanceFenceDefer, blockedTaskIds, this.#diagnosticAfterMs, this.#leaderWakeFence, this.#onError);
1179
+ taskResults[selected.index] = await runControllerSchedulerPass(this.store, this.delivery, this.#now(), this.#workspacePreparer, { kind: "dirty", keys: selected.taskScope.keys }, false, taskCleanupOutcomes[selected.index], this.#lifecycleHost, this.#stallWindowMs, this.#maintenanceFence, this.#onMaintenanceFenceDefer, blockedTaskIds, this.#diagnosticAfterMs, this.#leaderWakeFence, this.#onError, this.#phases);
1157
1180
  this.#clearTaskPassRetry(selected.taskScope.taskId);
1158
1181
  }
1159
1182
  catch (error) {