@zq-silk/yui 2.17.0 → 2.18.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 (70) hide show
  1. package/dist/cli/commandCatalog.js +2 -1
  2. package/dist/commands/controllerCommands.js +13 -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 +35 -22
  10. package/dist/controller/diagnosticJournal.js +214 -0
  11. package/dist/controller/fileSchedulerStoreAdapter.js +139 -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/runtime.js +66 -4
  18. package/dist/controller/workerSchedulerStore.js +24 -0
  19. package/dist/core/boundedRpc.js +4 -0
  20. package/dist/core/controllerServer.js +1 -1
  21. package/dist/core/controllerTelemetry.js +92 -19
  22. package/dist/job/globalShell.js +51 -0
  23. package/dist/job/jobBusinessResult.js +4 -3
  24. package/dist/job/jobDefinition.js +6 -1
  25. package/dist/job/jobRunner.js +9 -0
  26. package/dist/job/scheduleService.js +2 -0
  27. package/dist/message/globalInboxRecovery.js +30 -19
  28. package/dist/message/globalProviderRetry.js +7 -1
  29. package/dist/runtime/builtinAgentErrorMappers.js +4 -0
  30. package/dist/runtime/globalSessionHealth.js +39 -0
  31. package/dist/scheduler/roleRunStall.js +10 -10
  32. package/dist/storage/persistenceWorker.js +41 -19
  33. package/dist/storage/sqliteSchema.js +35 -11
  34. package/dist/storage/sqliteStore.js +86 -8
  35. package/dist/storage/storageVersions.js +1 -1
  36. package/dist/storage/storeRpc.js +25 -11
  37. package/dist/storage/taskReadRevisionSchema.js +39 -0
  38. package/dist/web/assets/client/app/catalog.js +0 -29
  39. package/dist/web/assets/client/app/jobs.js +115 -0
  40. package/dist/web/assets/client/app/main.js +17 -6
  41. package/dist/web/assets/client/app/manage.js +1 -0
  42. package/dist/web/assets/client/app/settings.js +1 -5
  43. package/dist/web/assets/client/i18n/en.js +203 -18
  44. package/dist/web/assets/client/i18n/zh-CN.js +202 -17
  45. package/dist/web/assets/client/index.js +2 -0
  46. package/dist/web/assets/client/layout/manageShell.js +61 -15
  47. package/dist/web/assets/client/layout/router.js +2 -1
  48. package/dist/web/assets/client/lib/prefs.js +1 -1
  49. package/dist/web/assets/client/views/jobs.js +1042 -160
  50. package/dist/web/assets/client/views/sidebar.js +3 -0
  51. package/dist/web/assets/shared/manageNav.js +3 -1
  52. package/dist/web/assets/shell/appRail.js +2 -1
  53. package/dist/web/assets/shell/manage.js +7 -2
  54. package/dist/web/assets/shell/models.js +4 -3
  55. package/dist/web/assets/shell/settingsContent.js +0 -3
  56. package/dist/web/assets/shell/sidebar.js +0 -1
  57. package/dist/web/assets/styles/views/jobs.js +301 -12
  58. package/dist/web/assets/styles/views/sidebar.js +1 -2
  59. package/dist/web/webServer.js +28 -1
  60. package/docs/global-shell-jobs.md +78 -0
  61. package/docs/job-runs.md +6 -3
  62. package/docs/managed-turn-and-session-runtime.md +74 -0
  63. package/docs/managed-turn-and-session-runtime.zh-CN.md +51 -0
  64. package/docs/release-workflow.md +43 -0
  65. package/docs/release-workflow.zh-CN.md +29 -0
  66. package/docs/storage-baseline.md +14 -1
  67. package/docs/storage-baseline.zh-CN.md +6 -1
  68. package/docs/testing/verification-levels.md +2 -2
  69. package/docs/testing/verification-levels.zh-CN.md +2 -2
  70. 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,16 @@ 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
+ scheduler: (runtime.scheduler ?? null),
191
+ preRoutingWait: "unmeasured; event-loop lag is sampled, not per-request queue time",
192
+ clock: "process-monotonic milliseconds; target/request labels are sha256 prefixes"
193
+ },
185
194
  queueDepth,
186
195
  oldestInFlightAgeMs: typeof routes?.oldestInFlightAgeMs === "number"
187
196
  ? routes.oldestInFlightAgeMs
@@ -210,7 +219,7 @@ export function renderRuntimeIdentitySection(input) {
210
219
  }
211
220
  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
221
  ? `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
222
+ : "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
223
  ? UNSUPPORTED
215
224
  : runtime.oldestInFlightAgeMs === null
216
225
  ? "—"
@@ -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
@@ -694,7 +696,10 @@ export class FileTaskController {
694
696
  #providerRetry;
695
697
  #continuationReconciler;
696
698
  #hostTerminalRecovery;
699
+ #persistenceMetrics;
700
+ #diagnosticMetrics;
697
701
  #current;
702
+ #phases = new ControllerWorkObserver();
698
703
  #operatorCurrent;
699
704
  #runtimeObserverCurrent;
700
705
  #pendingFull = false;
@@ -712,6 +717,8 @@ export class FileTaskController {
712
717
  constructor(store, delivery, options = {}) {
713
718
  this.store = store;
714
719
  this.delivery = delivery;
720
+ this.#persistenceMetrics = options.persistenceMetrics;
721
+ this.#diagnosticMetrics = options.diagnosticMetrics;
715
722
  this.#intervalMs = positiveInteger(options.intervalMs, DEFAULT_RECONCILIATION_INTERVAL_MS, "Controller reconciliation interval");
716
723
  this.#now = options.now ?? (() => new Date());
717
724
  this.#onError = options.onError ?? (() => { });
@@ -782,6 +789,12 @@ export class FileTaskController {
782
789
  runtimeMetrics() {
783
790
  const metrics = this.#lastRuntimeDrain?.metrics ?? ZERO_DRAIN_METRICS;
784
791
  return {
792
+ diagnostics: this.#diagnosticMetrics?.() ?? null,
793
+ phases: this.#phases.snapshot(),
794
+ persistence: this.#persistenceMetrics?.() ?? null,
795
+ reads: this.store.readObservationMetrics?.() ?? null,
796
+ scheduler: { running: this.#current !== undefined, pendingFull: this.#pendingFull,
797
+ pendingKeys: this.#pendingKeys.size },
785
798
  // Status is intentionally O(1): scanning the inbox while serving the
786
799
  // control socket would recreate the starvation this metric diagnoses.
787
800
  inbox: {
@@ -956,7 +969,7 @@ export class FileTaskController {
956
969
  * without bypassing the scheduler's own ownership and delivery fences. */
957
970
  async #observeMaintenance(phase, action) {
958
971
  try {
959
- return await action();
972
+ return await this.#phases.observe(phase, "home", action);
960
973
  }
961
974
  catch (error) {
962
975
  this.#onError(new Error(`${phase} failed: ${error instanceof Error ? error.message : String(error)}. `
@@ -998,12 +1011,12 @@ export class FileTaskController {
998
1011
  }
999
1012
  }
1000
1013
  }
1001
- const firstRuntimeDrain = await this.#drainRuntimeEvents();
1014
+ const firstRuntimeDrain = await this.#phases.observe("runtime-drain", "home", () => this.#drainRuntimeEvents());
1002
1015
  if (scope.kind === "full" && this.#hostTerminalRecovery !== undefined) {
1003
1016
  await this.#observeMaintenance("Host terminal recovery", this.#hostTerminalRecovery);
1004
1017
  }
1005
- await this.#providerRetry?.reconcile();
1006
- await this.#globalInputDelivery?.({ reconcile: scope.kind === "full", now: this.#now() });
1018
+ await this.#phases.observe("provider-retry", "home", () => this.#providerRetry?.reconcile());
1019
+ await this.#phases.observe("global-input", "home", () => this.#globalInputDelivery?.({ reconcile: scope.kind === "full", now: this.#now() }));
1007
1020
  for (const taskId of runtimeTaskFailureIds(firstRuntimeDrain)) {
1008
1021
  runtimeFailedTaskIds.add(taskId);
1009
1022
  }
@@ -1016,17 +1029,17 @@ export class FileTaskController {
1016
1029
  // DurableJob reconciliation runs before the scheduler pass so a
1017
1030
  // terminal job's Leader wakeup is enqueued in the same pass that
1018
1031
  // processes Leader wakeups.
1019
- this.#schedules?.reconcile(this.#now());
1032
+ await this.#schedules?.reconcile(this.#now());
1020
1033
  this.#jobSupervisor?.reconcile(this.#now());
1021
1034
  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);
1035
+ 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
1036
  }
1024
1037
  else {
1025
- const dirtyPass = await this.#runDirtySchedulerPass(scope, runtimeCleanupOutcomes, runtimeFailedTaskIds);
1038
+ const dirtyPass = await this.#phases.observe("scheduler-dirty", scope.keys.slice(0, 16).join(","), () => this.#runDirtySchedulerPass(scope, runtimeCleanupOutcomes, runtimeFailedTaskIds));
1026
1039
  result = dirtyPass.result;
1027
1040
  failedTaskScopes = dirtyPass.failedTaskScopes;
1028
1041
  }
1029
- const secondRuntimeDrain = await this.#drainRuntimeEvents();
1042
+ const secondRuntimeDrain = await this.#phases.observe("runtime-drain", "home", () => this.#drainRuntimeEvents());
1030
1043
  for (const taskId of runtimeTaskFailureIds(secondRuntimeDrain)) {
1031
1044
  runtimeFailedTaskIds.add(taskId);
1032
1045
  }
@@ -1040,7 +1053,7 @@ export class FileTaskController {
1040
1053
  // scheduler.
1041
1054
  if (scope.kind === "full" && this.#resourceAutoGc !== undefined) {
1042
1055
  try {
1043
- const gc = await this.#resourceAutoGc();
1056
+ const gc = await this.#phases.observe("resource-gc", "home", this.#resourceAutoGc);
1044
1057
  if (!gc.skipped && gc.failed > 0) {
1045
1058
  this.#onError(new Error(`Resource auto-GC failed to quarantine ${gc.failed} `
1046
1059
  + "resource(s); they stay in place and retry next pass."));
@@ -1120,7 +1133,7 @@ export class FileTaskController {
1120
1133
  const taskScopes = partition.taskScopes.filter((taskScope) => (!blockedTaskIds.has(taskScope.taskId)));
1121
1134
  const orderedResults = [];
1122
1135
  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));
1136
+ 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
1137
  }
1125
1138
  if (taskScopes.length === 0
1126
1139
  || this.#stopped
@@ -1153,7 +1166,7 @@ export class FileTaskController {
1153
1166
  if (this.#stopped || this.#pendingFull)
1154
1167
  continue;
1155
1168
  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);
1169
+ 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
1170
  this.#clearTaskPassRetry(selected.taskScope.taskId);
1158
1171
  }
1159
1172
  catch (error) {