@zq-silk/yui 2.16.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 (118) hide show
  1. package/dist/cli/commandCatalog.js +19 -12
  2. package/dist/cli/interactionCandidates.js +20 -5
  3. package/dist/cli/interactionPolicy.js +5 -4
  4. package/dist/cli/roleWizard.js +7 -0
  5. package/dist/commands/controllerCommands.js +13 -4
  6. package/dist/commands/durableJobCommands.js +50 -3
  7. package/dist/commands/globalRoleCommands.js +31 -14
  8. package/dist/commands/inboxCommands.js +1 -0
  9. package/dist/commands/operatorCommands.js +12 -2
  10. package/dist/commands/profileCommands.js +19 -2
  11. package/dist/commands/roleConfiguration.js +3 -0
  12. package/dist/commands/taskCommands.js +16 -0
  13. package/dist/commands/taskRoleRuntimeStatus.js +3 -0
  14. package/dist/controlPlaneCli.js +21 -4
  15. package/dist/controller/agentJobControl.js +9 -1
  16. package/dist/controller/clientRuntime.js +34 -13
  17. package/dist/controller/controller.js +35 -22
  18. package/dist/controller/diagnosticJournal.js +214 -0
  19. package/dist/controller/fileSchedulerStoreAdapter.js +174 -105
  20. package/dist/controller/globalInputDelivery.js +19 -9
  21. package/dist/controller/globalRuntimeAttention.js +11 -3
  22. package/dist/controller/jobControl.js +1 -1
  23. package/dist/controller/jobDefinitionControl.js +24 -13
  24. package/dist/controller/jobSupervisor.js +4 -0
  25. package/dist/controller/providerRetryAdmission.js +9 -0
  26. package/dist/controller/providerRetryDelivery.js +16 -0
  27. package/dist/controller/runtime.js +80 -4
  28. package/dist/controller/runtimeLaunchCoordinator.js +3 -1
  29. package/dist/controller/workerSchedulerStore.js +24 -0
  30. package/dist/core/boundedRpc.js +4 -0
  31. package/dist/core/controllerServer.js +1 -1
  32. package/dist/core/controllerTelemetry.js +92 -19
  33. package/dist/executor/fileRoleLaunchPlanner.js +3 -0
  34. package/dist/job/globalShell.js +51 -0
  35. package/dist/job/jobBusinessResult.js +4 -3
  36. package/dist/job/jobDefinition.js +6 -1
  37. package/dist/job/jobRunner.js +9 -0
  38. package/dist/job/scheduleService.js +2 -0
  39. package/dist/message/globalInboxRecovery.js +30 -19
  40. package/dist/message/globalProviderRetry.js +7 -1
  41. package/dist/message/messageContinuation.js +4 -0
  42. package/dist/output/rolePresentation.js +3 -0
  43. package/dist/profile/agentProfile.js +8 -0
  44. package/dist/profile/agentProfileRuntime.js +16 -1
  45. package/dist/review/reviewDecision.js +6 -0
  46. package/dist/role/availability.js +28 -0
  47. package/dist/role/role.js +6 -0
  48. package/dist/runtime/builtinAgentErrorMappers.js +4 -0
  49. package/dist/runtime/codexNativeAccess.js +21 -4
  50. package/dist/runtime/globalSessionHealth.js +39 -0
  51. package/dist/runtime/tmuxAdapters.js +5 -0
  52. package/dist/scheduler/activeRoleRunDelivery.js +9 -0
  53. package/dist/scheduler/leaderWakeupProcessor.js +8 -1
  54. package/dist/scheduler/roleRunLiveness.js +3 -0
  55. package/dist/scheduler/roleRunStall.js +10 -10
  56. package/dist/storage/persistenceWorker.js +41 -19
  57. package/dist/storage/sqliteSchema.js +43 -9
  58. package/dist/storage/sqliteStore.js +86 -8
  59. package/dist/storage/storageVersions.js +1 -1
  60. package/dist/storage/storeRpc.js +25 -11
  61. package/dist/storage/taskReadRevisionSchema.js +39 -0
  62. package/dist/web/assets/client/app/catalog.js +0 -29
  63. package/dist/web/assets/client/app/jobs.js +115 -0
  64. package/dist/web/assets/client/app/main.js +32 -28
  65. package/dist/web/assets/client/app/manage.js +1 -0
  66. package/dist/web/assets/client/app/settings.js +1 -5
  67. package/dist/web/assets/client/app/taskView.js +7 -11
  68. package/dist/web/assets/client/i18n/en.js +265 -36
  69. package/dist/web/assets/client/i18n/zh-CN.js +264 -35
  70. package/dist/web/assets/client/index.js +4 -0
  71. package/dist/web/assets/client/layout/manageShell.js +61 -15
  72. package/dist/web/assets/client/layout/router.js +2 -1
  73. package/dist/web/assets/client/layout/shortcuts.js +2 -2
  74. package/dist/web/assets/client/layout/workspace.js +99 -112
  75. package/dist/web/assets/client/lib/prefs.js +1 -1
  76. package/dist/web/assets/client/ui/primitives.js +15 -2
  77. package/dist/web/assets/client/views/dock/conversation.js +421 -123
  78. package/dist/web/assets/client/views/dock/session.js +229 -0
  79. package/dist/web/assets/client/views/dock/terminal.js +48 -39
  80. package/dist/web/assets/client/views/jobs.js +1042 -160
  81. package/dist/web/assets/client/views/overview.js +2 -2
  82. package/dist/web/assets/client/views/sidebar.js +3 -0
  83. package/dist/web/assets/client/views/task/page.js +2 -12
  84. package/dist/web/assets/shared/manageNav.js +3 -1
  85. package/dist/web/assets/shell/appRail.js +2 -1
  86. package/dist/web/assets/shell/dock.js +9 -27
  87. package/dist/web/assets/shell/manage.js +7 -2
  88. package/dist/web/assets/shell/models.js +4 -3
  89. package/dist/web/assets/shell/settingsContent.js +0 -3
  90. package/dist/web/assets/shell/sidebar.js +0 -1
  91. package/dist/web/assets/styles/views/conversation.js +96 -17
  92. package/dist/web/assets/styles/views/dock.js +17 -5
  93. package/dist/web/assets/styles/views/jobs.js +301 -12
  94. package/dist/web/assets/styles/views/sidebar.js +1 -2
  95. package/dist/web/assets/styles/views/task.js +1 -2
  96. package/dist/web/assets/styles/views/terminal.js +12 -8
  97. package/dist/web/webConversation.js +1 -0
  98. package/dist/web/webServer.js +28 -1
  99. package/dist/web/webSettings.js +10 -2
  100. package/docs/global-shell-jobs.md +78 -0
  101. package/docs/job-runs.md +6 -3
  102. package/docs/managed-turn-and-session-runtime.md +84 -0
  103. package/docs/managed-turn-and-session-runtime.zh-CN.md +57 -0
  104. package/docs/release-workflow.md +67 -0
  105. package/docs/release-workflow.zh-CN.md +48 -0
  106. package/docs/roles-and-configuration.md +45 -1
  107. package/docs/roles-and-configuration.zh-CN.md +35 -0
  108. package/docs/storage-baseline.md +19 -1
  109. package/docs/storage-baseline.zh-CN.md +10 -1
  110. package/docs/testing/review-evaluation.md +35 -1
  111. package/docs/testing/review-evaluation.zh-CN.md +25 -1
  112. package/docs/testing/verification-levels.md +2 -2
  113. package/docs/testing/verification-levels.zh-CN.md +2 -2
  114. package/package.json +1 -1
  115. package/skills/yui-leader/VERSION +1 -1
  116. package/skills/yui-leader/references/execution.md +19 -8
  117. package/skills/yui-reviewer/SKILL.md +13 -2
  118. package/skills/yui-reviewer/VERSION +1 -1
@@ -102,15 +102,19 @@ export function runtimeDiagnosticEvidence(messages, key) {
102
102
  /** Global observations have no Task event history. Keep original failed
103
103
  * attempts as ordinary record-only Messages before replacing the live binding. */
104
104
  export function recordGlobalRuntimeFailure(store, roleName, identity, facts, now) {
105
+ return recordFailure(store, roleName, identity, facts, now, [...store.listGlobalRoleMessages(roleName)]);
106
+ }
107
+ function recordFailure(store, roleName, identity, facts, now, messages) {
105
108
  const diagnostic = diagnosticIdentity(store, roleName, identity, facts);
106
109
  const header = `${FAILURE_HEADER}${hash([identity, diagnostic.key])}\n`;
107
- const previous = store.listGlobalRoleMessages(roleName).find(message => message.kind === "system" && message.body.startsWith(header));
110
+ const previous = messages.find(message => message.kind === "system" && message.body.startsWith(header));
108
111
  if (previous !== undefined)
109
112
  return `${roleName}/${previous.id}`;
110
113
  const message = createGlobalRoleMessage(store.nextGlobalRoleMessageId(), roleName, header + redactAgentErrorText(JSON.stringify({
111
114
  ...object(facts), diagnostic
112
115
  })), "system", { type: "system" }, now);
113
116
  store.saveGlobalRoleMessage(message);
117
+ messages.push(message);
114
118
  return `${roleName}/${message.id}`;
115
119
  }
116
120
  /** Reuse the Global Message inbox for an exact failure, not a recovery queue.
@@ -119,9 +123,13 @@ export function recordGlobalRuntimeAttention(store, roleName, identity, facts, n
119
123
  // A Home may have a custom Global Role without an Operator. Keep its own
120
124
  // diagnostic readable; never create a Role or launch a substitute Agent.
121
125
  const recipient = store.getGlobalRole("operator") === null ? roleName : "operator";
122
- const failureRef = recordGlobalRuntimeFailure(store, roleName, identity, facts, now);
126
+ // These observations run in the caller's transaction. Reuse that exact
127
+ // history plus our appended record, rather than loading/parsing the whole
128
+ // Role history twice on every unchanged Host failure.
129
+ const messages = [...store.listGlobalRoleMessages(roleName)];
130
+ const failureRef = recordFailure(store, roleName, identity, facts, now, messages);
123
131
  const diagnostic = diagnosticIdentity(store, roleName, identity, facts);
124
- const group = runtimeDiagnosticGroups(store.listGlobalRoleMessages(roleName))
132
+ const group = runtimeDiagnosticGroups(messages)
125
133
  .reverse().find(group => group.key === diagnostic.key && group.recoveredAt === undefined);
126
134
  const requestId = `runtime-attention:${hash([roleName, diagnostic.key, group?.episode ?? identity])}`;
127
135
  if (store.queryGlobalRoleMessages(recipient, { requestId, limit: 1 }).messages.length > 0)
@@ -277,7 +277,7 @@ export function assertNonSecretJobInput(params) {
277
277
  }
278
278
  }
279
279
  const input = JSON.stringify({
280
- env: params.env, steps: params.steps, requestId: params.requestId
280
+ env: params.env, steps: params.steps, requestId: params.requestId, files: params.files
281
281
  });
282
282
  // Reject recognizable key material regardless of the parameter name, and
283
283
  // URL userinfo before either hashing or persisting the specification.
@@ -5,6 +5,7 @@ import { requireKnownFields } from "../domain/validation.js";
5
5
  import { createJobDefinition, reviseJobDefinition, jobScopeKey, validateJobDefinition } from "../job/jobDefinition.js";
6
6
  import { acknowledgeUnknownDurableJob, requestDurableJobCancel, validateDurableJob, JOB_RUNNER_IMPLEMENTATION, executionNamespace } from "../job/durableJob.js";
7
7
  import { prepareBuiltinJob, projectRefreshDigest } from "../job/builtinJob.js";
8
+ import { prepareGlobalShell } from "../job/globalShell.js";
8
9
  import { projectRefreshTargetDigest, requireRefreshProject } from "../repository/projectRefresh.js";
9
10
  import { assertCallerAuthorized, assertNonSecretJobInput, createDurableJobControl, jobAuthorityBinding, validateStartParams, requireJobAssignment, parseDurableJobCaller } from "./jobControl.js";
10
11
  import { createFileArtifactPort } from "./jobSupervisor.js";
@@ -38,7 +39,7 @@ export function authorizeScope(store, scope, caller, allowPublic = false) {
38
39
  assertCallerAuthorized(store, caller, scope.kind === "task" ? scope.taskId : "");
39
40
  }
40
41
  function taskParams(job, caller, requestId) {
41
- if (job.scope.kind !== "task" || job.configuration.kind !== "shell")
42
+ if (job.scope.kind !== "task" || job.configuration.kind !== "shell" || !("owner" in job.configuration))
42
43
  throw new Error("Not a Task Job.");
43
44
  return { ...job.configuration, taskId: job.scope.taskId, caller, requestId };
44
45
  }
@@ -60,8 +61,13 @@ export function authorizeDefinition(store, job, caller, write) {
60
61
  assertNonSecretJobInput(params);
61
62
  }
62
63
  else if (write) {
64
+ if (job.configuration.kind === "shell") {
65
+ prepareGlobalShell(job.configuration, "/job-validation");
66
+ assertNonSecretJobInput({ ...job.configuration, requestId: "global-shell-definition" });
67
+ return;
68
+ }
63
69
  if (job.configuration.kind !== "builtin")
64
- throw new Error("Global Jobs require builtin actions.");
70
+ throw new Error("Not a global process Job.");
65
71
  prepareBuiltinJob(job.configuration, "/");
66
72
  if (job.configuration.action === "project.refresh") {
67
73
  if (job.builtin)
@@ -190,18 +196,21 @@ export function admitJobRun(store, home, scope, definitionId, caller, trigger, n
190
196
  }, now);
191
197
  return { run: result.job, created: result.created };
192
198
  }
193
- if (job.configuration.kind !== "builtin")
194
- throw new Error("Global Jobs require builtin actions.");
199
+ if (job.configuration.kind !== "builtin" && job.configuration.kind !== "shell")
200
+ throw new Error("Not a global process Job.");
195
201
  const id = `job-${randomUUID()}`;
196
202
  const locator = `artifacts/jobs/global/${id}`;
197
- const spec = prepareBuiltinJob(job.configuration, join(home, locator), { home, runId: id });
203
+ const config = job.configuration;
204
+ const shell = config.kind === "shell";
205
+ const spec = shell ? prepareGlobalShell(config, join(home, locator, "workspace"))
206
+ : prepareBuiltinJob(config, join(home, locator), { home, runId: id });
198
207
  const actorId = service ? "service:schedule" : caller.scope === "user" ? "user" : `global:${caller.role}`;
199
208
  let run = {
200
209
  schemaVersion: 1, scope: "global", id, definitionId: job.id, definitionRevision: job.revision,
201
- trigger: structuredClone(trigger), action: job.configuration.action,
210
+ trigger: structuredClone(trigger), action: shell ? "shell.exec" : config.action,
202
211
  ...(service === undefined ? {} : { serviceAuthorization: structuredClone(service) }),
203
- actionInput: structuredClone(job.configuration.input),
204
- ...(job.configuration.projectId === undefined ? {} : { projectId: job.configuration.projectId }),
212
+ actionInput: structuredClone(shell ? config : config.input),
213
+ ...(!shell && config.projectId !== undefined ? { projectId: config.projectId } : {}),
205
214
  ...spec, status: "queued", artifactsLocator: locator,
206
215
  idempotencyKey: createHash("sha256").update(JSON.stringify([actorId, requestId])).digest("hex"),
207
216
  operation: {
@@ -280,10 +289,12 @@ export function authorizeGlobalJobStart(store, run) {
280
289
  const projects = run.action === "project.refresh" ? store.listProjects() : [];
281
290
  const home = store.rootDirectory();
282
291
  try {
283
- const compiled = prepareBuiltinJob({
284
- kind: "builtin", action: run.action, input: run.actionInput,
285
- ...(run.projectId === undefined ? {} : { projectId: run.projectId })
286
- }, run.workspace, { home, runId: run.id });
292
+ const compiled = run.action === "shell.exec"
293
+ ? prepareGlobalShell(run.actionInput, join(home, "artifacts/jobs/global", run.id, "workspace"))
294
+ : prepareBuiltinJob({
295
+ kind: "builtin", action: run.action, input: run.actionInput,
296
+ ...(run.projectId === undefined ? {} : { projectId: run.projectId })
297
+ }, run.workspace, { home, runId: run.id });
287
298
  if (run.action === "project.refresh") {
288
299
  const definition = store.getJobDefinition({ kind: "global" }, run.definitionId);
289
300
  if (definition?.builtin)
@@ -297,7 +308,7 @@ export function authorizeGlobalJobStart(store, run) {
297
308
  throw new Error("Project refresh Run workspace does not match its artifact directory.");
298
309
  }
299
310
  }
300
- if (JSON.stringify(compiled.steps) !== JSON.stringify(run.steps)
311
+ if (compiled.workspace !== run.workspace || JSON.stringify(compiled.steps) !== JSON.stringify(run.steps)
301
312
  || JSON.stringify(compiled.env) !== JSON.stringify(run.env) || compiled.head !== run.head) {
302
313
  throw new Error("Global JobRun does not match its controlled action.");
303
314
  }
@@ -199,6 +199,8 @@ export class DurableJobSupervisor {
199
199
  return;
200
200
  }
201
201
  const spec = {
202
+ ...(!("taskId" in job) && "action" in job && job.action === "shell.exec"
203
+ ? { files: job.actionInput.files } : {}),
202
204
  jobId: job.id,
203
205
  ...("taskId" in job ? { taskId: job.taskId } : { scope: "global" }),
204
206
  workspace: job.workspace,
@@ -386,6 +388,8 @@ export class DurableJobSupervisor {
386
388
  return fallback;
387
389
  if (job.action === "system.noop")
388
390
  return { ...fallback, data: { action: "system.noop" } };
391
+ if (job.action === "shell.exec")
392
+ return fallback;
389
393
  const raw = this.#artifacts.readBusinessResult?.(executionNamespace(job), job.id);
390
394
  if (raw !== null && raw !== undefined) {
391
395
  try {
@@ -1,3 +1,4 @@
1
+ import { assertRoleEnabled, isEnabled } from "../role/availability.js";
1
2
  import { admitProviderRetry, cancelProviderRetry, providerRetryAttemptId, providerRetryPending } from "../runtime/providerRetry.js";
2
3
  import { blockingProviderContinuations } from "../runtime/runtimeContinuationProjection.js";
3
4
  import { validateExactRunReviewRound } from "../lifecycle/exactRunTerminalization.js";
@@ -20,6 +21,8 @@ export function ownedProviderInput(input) {
20
21
  export function retryIntentBlocker(store, input, retry) {
21
22
  if (input.taskId === undefined) {
22
23
  const role = store.getGlobalRole(input.roleName);
24
+ if (role !== null && !isEnabled(role))
25
+ return "role-disabled";
23
26
  const sessions = store.getGlobalRoleSessionSet(input.roleName);
24
27
  if (role === null || sessions?.activeAgentId !== role.activeAgentId)
25
28
  return "role-changed";
@@ -38,6 +41,8 @@ export function retryIntentBlocker(store, input, retry) {
38
41
  || task.executionGate.state !== "enabled")
39
42
  return "task-execution-stopped";
40
43
  const role = store.getRole(input.taskId, input.roleName);
44
+ if (role !== null && !isEnabled(role))
45
+ return "role-disabled";
41
46
  const sessions = store.getTaskRoleSessionSet(input.taskId, input.roleName);
42
47
  if (role === null || sessions?.activeAgentId !== role.activeAgentId)
43
48
  return "role-changed";
@@ -54,6 +59,10 @@ export function retryIntentBlocker(store, input, retry) {
54
59
  /** Shared by automatic recovery and every ordinary Host submit. The native
55
60
  * writer CAS remains the only grant; a scheduled successor is not a grant. */
56
61
  export function admitOwnedProviderInput(store, binding, input) {
62
+ const role = input.taskId === undefined ? store.getGlobalRole(input.roleName)
63
+ : store.getRole(input.taskId, input.roleName);
64
+ if (role !== null)
65
+ assertRoleEnabled(role);
57
66
  const retry = binding.retry;
58
67
  const sameReviewSuccessor = retry?.successorReviewRoundId !== undefined && input.taskId !== undefined && input.runId !== undefined
59
68
  && store.getRun(input.taskId, input.runId)?.reviewRoundId === retry.successorReviewRoundId;
@@ -1,4 +1,5 @@
1
1
  import { performance } from "node:perf_hooks";
2
+ import { isEnabled } from "../role/availability.js";
2
3
  import { runTaskCommand } from "../commands/taskCommands.js";
3
4
  import { routeRoleEvent } from "../scheduler/operatorEvent.js";
4
5
  import { createTaskEvent } from "../event/taskEvent.js";
@@ -64,12 +65,19 @@ export function createProviderRetryHooks(home, store, ports = {}) {
64
65
  const key = (set) => set.owner.scope === "task"
65
66
  ? `role:${encodeURIComponent(set.owner.taskId)}/${encodeURIComponent(set.owner.roleName)}`
66
67
  : `global-role:${encodeURIComponent(set.owner.roleName)}`;
68
+ const disabled = (set) => {
69
+ const role = set.owner.scope === "task"
70
+ ? store.getRole(set.owner.taskId, set.owner.roleName) : store.getGlobalRole(set.owner.roleName);
71
+ return role !== null && !isEnabled(role);
72
+ };
67
73
  return {
68
74
  deadlines() {
69
75
  return store.listProviderRetrySessions().flatMap(set => {
70
76
  const retry = set.providerBinding.retry;
71
77
  if (retry.status !== "waiting")
72
78
  return [];
79
+ if (disabled(set))
80
+ return [];
73
81
  // Busy or already-queued work awaits the next ordinary pass without a
74
82
  // zero-delay timer loop. Waiting never consumes an execution attempt.
75
83
  const wall = (ports.now ?? (() => new Date()))().getTime();
@@ -97,6 +105,10 @@ export function createProviderRetryHooks(home, store, ports = {}) {
97
105
  const scoped = { ...(owner.scope === "task" ? { taskId: owner.taskId } : {}), roleName: owner.roleName };
98
106
  const session = set.sessions[set.activeAgentId];
99
107
  const blocker = retryIntentBlocker(store, scoped, retry);
108
+ // Configuration pause neither settles input nor cancels an already
109
+ // admitted attempt. Normal ingress still records its exact terminal.
110
+ if (blocker === "role-disabled")
111
+ continue;
100
112
  if (blocker !== undefined && blocker !== "background-work-unsettled") {
101
113
  stop(set, blocker, at);
102
114
  continue;
@@ -175,6 +187,10 @@ export function createProviderRetryHooks(home, store, ports = {}) {
175
187
  || latest.status !== "waiting")
176
188
  return;
177
189
  const reason = retryIntentBlocker(store, scoped, latest);
190
+ // Snapshotting yields to configuration changes. Apply the same
191
+ // pause semantics as the pre-await check, without settling intent.
192
+ if (reason === "role-disabled")
193
+ return;
178
194
  if (reason !== undefined)
179
195
  throw new Error(reason);
180
196
  runTaskCommand(["run", "retry", `${owner.taskId}/${previous.id}`], store, {
@@ -1,7 +1,10 @@
1
1
  import { isDeepStrictEqual } from "node:util";
2
+ import { ControllerDiagnosticJournal } from "./diagnosticJournal.js";
3
+ import { monotonicMilliseconds } from "../core/controllerTelemetry.js";
2
4
  import { prepareNativeHostSteer } from "./nativeHostInput.js";
3
5
  import { archiveLeaderTask } from "../task/leaderArchive.js";
4
6
  import { TaskWorkspaceCoordinator } from "../repository/taskWorkspaceCoordinator.js";
7
+ import { assertRoleEnabled, RoleDisabledError } from "../role/availability.js";
5
8
  import { AGENT_OPERATIONAL_ENVIRONMENT_NAMES, NATIVE_AGENT_ENVIRONMENT_NAMES, nativeAgentEnvironmentNames, YUI_MANAGED_RUNTIME_ENVIRONMENT_NAMES } from "../agent/launchEnvironment.js";
6
9
  import { runPurposeAdmitsTaskState } from "../agentRun/agentRun.js";
7
10
  import { reconciliationIntervalMilliseconds, resolveAgentLaunchInactivityTimeoutSeconds, resolveControllerTaskConcurrency, resolveDeliveryTimeoutSeconds, resolveRuntimeHealth, resolveTmuxBin, resolveTmuxHistoryLimit } from "../config/yuiConfig.js";
@@ -60,7 +63,6 @@ import { authorizeJobStart } from "./jobControl.js";
60
63
  import { authorizeGlobalJobStart, createJobDefinitionControl } from "./jobDefinitionControl.js";
61
64
  import { reconcileTaskDelegation } from "./jobTaskDelegationControl.js";
62
65
  import { createScheduleControl, reconcileSchedules } from "./scheduleControl.js";
63
- import { reconcileProjectSync } from "../job/projectSync.js";
64
66
  import { createFileArtifactPort, createLinuxProcessPort, DurableJobSupervisor } from "./jobSupervisor.js";
65
67
  import { createProviderRetryHooks } from "./providerRetryDelivery.js";
66
68
  import { createRuntimeResourceActivityTracker } from "./resourceInventory.js";
@@ -71,8 +73,54 @@ import { AsyncRuntimeEventProcessor, createAsyncRuntimeObserver, FileRuntimeEven
71
73
  import { RuntimeLaunchCoordinator } from "./runtimeLaunchCoordinator.js";
72
74
  import { SessionOwnerReconciliation } from "./sessionOwnerReconciliation.js";
73
75
  import { readTaskAgentCapabilities } from "./agentCapabilities.js";
76
+ import { workerSchedulerStore } from "./workerSchedulerStore.js";
77
+ import { deserializeError } from "../core/boundedRpc.js";
74
78
  /** Production composition root for the current SQLite TaskStore + tmux Controller. */
75
79
  export async function startFileTaskControllerRuntime(home, options = {}) {
80
+ const diagnostics = new ControllerDiagnosticJournal(home);
81
+ let timer;
82
+ const closeDiagnostics = async () => {
83
+ if (timer !== undefined)
84
+ clearInterval(timer);
85
+ await diagnostics.close();
86
+ };
87
+ try {
88
+ const running = await createFileTaskControllerRuntime(home, {
89
+ ...options,
90
+ diagnosticMetrics: () => diagnostics.metrics(),
91
+ onError: error => { diagnostics.error(error); options.onError?.(error); }
92
+ });
93
+ let expectedAt = monotonicMilliseconds() + 250;
94
+ timer = setInterval(() => {
95
+ const now = monotonicMilliseconds();
96
+ const lagMs = Math.max(0, now - expectedAt);
97
+ expectedAt = now + 250;
98
+ diagnostics.observe({
99
+ ...running.runtime.runtimeMetrics(),
100
+ commands: {
101
+ routes: running.server.commandObserver.snapshot(),
102
+ eventLoopDelay: running.server.eventLoopDelay.snapshot()
103
+ }
104
+ }, lagMs);
105
+ }, 250);
106
+ timer.unref();
107
+ const closed = running.closed.finally(closeDiagnostics);
108
+ return { ...running, closed, close: async () => {
109
+ try {
110
+ await running.close();
111
+ }
112
+ finally {
113
+ await closeDiagnostics();
114
+ }
115
+ } };
116
+ }
117
+ catch (error) {
118
+ diagnostics.error(error);
119
+ await closeDiagnostics();
120
+ throw error;
121
+ }
122
+ }
123
+ async function createFileTaskControllerRuntime(home, options) {
76
124
  // The current Home always uses SQLite. The persistence worker is enabled by
77
125
  // default; YUI_STORE_WORKER=0/false keeps the same database in-process.
78
126
  const useWorker = resolveStoreWorkerEnabledForHome(home, options.environment ?? process.env);
@@ -110,6 +158,10 @@ export async function startFileTaskControllerRuntime(home, options = {}) {
110
158
  closeTelemetry = () => telemetry?.sink.close() ?? Promise.resolve();
111
159
  const schedulerStore = options.schedulerStore
112
160
  ?? new FileSchedulerStoreAdapter(store, telemetry);
161
+ // Injected stores/adapters retain their owner and semantics. Only the
162
+ // production SQLite adapter shares the worker's exact Home and behavior.
163
+ const workerMaintenance = asyncStoreClient !== undefined
164
+ && options.store === undefined && options.schedulerStore === undefined;
113
165
  const domainIdentity = options.domainIdentity
114
166
  ?? ephemeralDomainFromEnvironment(options.environment ?? process.env);
115
167
  const planner = options.planner ?? new FileRoleLaunchPlanner(home, store, {
@@ -531,7 +583,9 @@ export async function startFileTaskControllerRuntime(home, options = {}) {
531
583
  const continuationReconciler = options.continuationMetadata === undefined
532
584
  ? undefined
533
585
  : new ProviderContinuationReconciliationService(store, schedulerStore, options.continuationMetadata, options.onError);
534
- const running = await startFileTaskController(home, schedulerStore, delivery, (method, params) => method === "task.role-capabilities"
586
+ const running = await startFileTaskController(home, workerMaintenance
587
+ ? workerSchedulerStore(schedulerStore, asyncStoreClient)
588
+ : schedulerStore, delivery, (method, params) => method === "task.role-capabilities"
535
589
  ? readTaskAgentCapabilities(store, catalogs, params)
536
590
  : method === "web.start" || method === "web.stop" || method === "web.status"
537
591
  ? web.dispatch(method, params) : lifecycleDispatcher(method, params), {
@@ -550,6 +604,7 @@ export async function startFileTaskControllerRuntime(home, options = {}) {
550
604
  ?? resolveRuntimeHealth(durableConfig.runtimeHealth).diagnosticAfterMs,
551
605
  now: options.now,
552
606
  onError: options.onError,
607
+ diagnosticMetrics: options.diagnosticMetrics,
553
608
  lifecycleHost,
554
609
  providerRetry: createProviderRetryHooks(home, store, {
555
610
  now: options.now,
@@ -580,8 +635,12 @@ export async function startFileTaskControllerRuntime(home, options = {}) {
580
635
  jobDefinitions: createJobDefinitionControl(store, home),
581
636
  schedules: {
582
637
  control: createScheduleControl(store, home),
583
- reconcile: now => {
584
- reconcileProjectSync(store, now, options.onError);
638
+ reconcile: async (now) => {
639
+ const errors = workerMaintenance
640
+ ? await asyncStoreClient.invokeObserver("reconcileProjectSync", [now])
641
+ : schedulerStore.reconcileProjectSync(now);
642
+ for (const error of errors)
643
+ options.onError?.(deserializeError(error));
585
644
  reconcileSchedules(store, home, now, options.onError);
586
645
  }
587
646
  },
@@ -599,6 +658,10 @@ export async function startFileTaskControllerRuntime(home, options = {}) {
599
658
  ?? (useWorker && asyncStoreClient !== undefined
600
659
  ? new AsyncRuntimeEventProcessor(runtimeEventInbox, createAsyncRuntimeObserver((method, args) => asyncStoreClient.invokeObserver(method, args)))
601
660
  : new FileRuntimeEventProcessor(runtimeEventInbox, schedulerStore)),
661
+ persistenceMetrics: () => ({
662
+ worker: asyncStoreClient?.observationMetrics() ?? null,
663
+ database: store instanceof SqliteTaskStore ? store.storageObservationMetrics() : null
664
+ }),
602
665
  runtimeObserver: options.runtimeObserver
603
666
  ?? new AgentRuntimeObserver(store, runtimeEventInbox),
604
667
  domainIdentity,
@@ -740,6 +803,17 @@ export function createRuntimeLifecycleDispatcher(store, schedulerStore, sessionH
740
803
  });
741
804
  }
742
805
  catch (error) {
806
+ if (error instanceof RoleDisabledError) {
807
+ const turn = (value.taskId === undefined
808
+ ? store.getGlobalRoleSessionSet(value.roleName)
809
+ : store.getTaskRoleSessionSet(value.taskId, value.roleName))?.providerBinding?.run;
810
+ // The admission transaction rolled back. Only a new, unregistered
811
+ // input may use the existing temporary-backpressure wire contract.
812
+ // A matching prior registration remains fenced, never a fresh retry.
813
+ if (turn?.attemptId !== value.attemptId) {
814
+ throw applicationError("SESSION_BUSY", error.message);
815
+ }
816
+ }
743
817
  if (error instanceof AgentHostProviderSessionBusyError)
744
818
  throw error;
745
819
  if (error instanceof AgentHostProviderTurnFenceError) {
@@ -960,6 +1034,7 @@ export function createRuntimeLifecycleDispatcher(store, schedulerStore, sessionH
960
1034
  ? activeRun?.workspace
961
1035
  ?? currentDesiredManagedWorkspace(store, request.taskId, request.roleName)
962
1036
  : undefined;
1037
+ assertRoleEnabled(role);
963
1038
  // Only Draft planning may launch without an adopted delivery workspace.
964
1039
  const workspaceFree = request.scope === "task"
965
1040
  && managedWorkspace === undefined
@@ -1082,6 +1157,7 @@ function assertRuntimeLaunchRequestCurrent(store, request) {
1082
1157
  if (role === null) {
1083
1158
  throw new Error(`Role no longer exists: ${request.owner.roleName}.`);
1084
1159
  }
1160
+ assertRoleEnabled(role);
1085
1161
  const sessions = request.owner.scope === "task"
1086
1162
  ? store.getTaskRoleSessionSet(request.owner.taskId, request.owner.roleName)
1087
1163
  : store.getGlobalRoleSessionSet(request.owner.roleName);
@@ -92,7 +92,9 @@ export class RuntimeLaunchCoordinator {
92
92
  ...(request.runId === undefined ? {} : { runId: request.runId }),
93
93
  ...(request.environment === undefined ? {} : { environment: request.environment })
94
94
  };
95
- const callback = beforeHostStart === undefined ? undefined : observePreflight;
95
+ // Even without a startup-facts consumer, recheck after asynchronous Host
96
+ // validation and immediately before creating the process.
97
+ const callback = beforeHostStart === undefined ? () => assertLaunchCurrent() : observePreflight;
96
98
  if (request.mode === "resume" && !request.nativeSessionId) {
97
99
  throw new Error("Restoring a Session requires its native session id.");
98
100
  }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Keep maintenance read/check/write closures together on the persistence
3
+ * worker. These operations recheck their exact durable condition in one
4
+ * transaction and are idempotent under the worker's existing replay contract.
5
+ * Do not add admission/claim operations here: their result and unknown-effect
6
+ * contracts need a separate audit before moving across this boundary.
7
+ */
8
+ const WORKER_MAINTENANCE = new Set([
9
+ "resolveExpiredInputRecommendations",
10
+ "recordRoleRunProgress",
11
+ "recordRoleRunDiagnostic",
12
+ "recordRoleRunStall"
13
+ ]);
14
+ export function workerSchedulerStore(store, client) {
15
+ return new Proxy(store, {
16
+ get(target, property) {
17
+ if (WORKER_MAINTENANCE.has(property)) {
18
+ return (...args) => client.invokeObserver(String(property), args);
19
+ }
20
+ const value = Reflect.get(target, property);
21
+ return typeof value === "function" ? value.bind(target) : value;
22
+ }
23
+ });
24
+ }
@@ -48,6 +48,10 @@ export function serializeError(error) {
48
48
  export function deserializeError(serialized) {
49
49
  const error = new Error(serialized.message);
50
50
  error.name = serialized.name;
51
+ if (serialized.code !== undefined)
52
+ Object.assign(error, { code: serialized.code });
53
+ if (serialized.stack !== undefined)
54
+ error.stack = serialized.stack;
51
55
  return error;
52
56
  }
53
57
  // -- Bounded slot pool (backpressure) ----------------------------------------
@@ -311,7 +311,7 @@ async function routeRequest(socket, line, token, dispatcher, stop, status, telem
311
311
  // observation completes when the response is handed to the socket, so a
312
312
  // later status snapshot sees prior built-in completions and the dispatcher
313
313
  // service-time metric stays a distinct observation.
314
- const observation = telemetry.commandObserver.start();
314
+ const observation = telemetry.commandObserver.start(request.method, request.id);
315
315
  const send = (response, onFlushed) => {
316
316
  observation.complete(isBuiltinControllerMethod(request.method) ? "builtin" : "dispatched", response.ok ? "success" : "failure");
317
317
  sendResponse(socket, response, onFlushed);
@@ -1,13 +1,69 @@
1
- /**
2
- * Pure-memory, fixed-space command observation for the Controller socket.
3
- *
4
- * The core server owns route/built-in command observation: every authenticated
5
- * request is counted once at the routing layer, and a bounded unrefed scheduler
6
- * samples event-loop delay so an already-written request's pre-dispatch wait is
7
- * observable even when the loop is saturated by scheduler projections. The
8
- * FileTaskController composes these snapshots into its runtime status metric;
9
- * nothing here is persisted and every snapshot is O(1).
10
- */
1
+ import { createHash } from "node:crypto";
2
+ /** Fixed-size retained diagnostics; live bookkeeping follows in-flight work.
3
+ * Not a log or scheduling authority. No params,
4
+ * prompts, paths, failure text or credentials are retained. Target/request
5
+ * hashes correlate an observation without publishing arbitrary input. */
6
+ export class ControllerWorkObserver {
7
+ clock;
8
+ #active = new Map();
9
+ #recent = [];
10
+ #completed = 0;
11
+ #maximumMs = 0;
12
+ #slowest = null;
13
+ constructor(clock = monotonicMilliseconds) {
14
+ this.clock = clock;
15
+ }
16
+ start(phase, target) {
17
+ const token = {};
18
+ const entry = { phase, target: target === undefined ? null
19
+ : createHash("sha256").update(target).digest("hex").slice(0, 16), startedAt: this.clock() };
20
+ this.#active.set(token, entry);
21
+ return (failed = false) => {
22
+ if (!this.#active.delete(token))
23
+ return;
24
+ const elapsedMs = Math.max(0, this.clock() - entry.startedAt);
25
+ this.#completed++;
26
+ this.#maximumMs = Math.max(this.#maximumMs, elapsedMs);
27
+ const completed = { phase, target: entry.target, startedAtMs: entry.startedAt, elapsedMs, failed };
28
+ if (this.#slowest === null || elapsedMs > this.#slowest.elapsedMs)
29
+ this.#slowest = completed;
30
+ this.#recent.push(completed);
31
+ if (this.#recent.length > 16)
32
+ this.#recent.shift();
33
+ };
34
+ }
35
+ async observe(phase, target, action) {
36
+ const finish = this.start(phase, target);
37
+ try {
38
+ const value = await action();
39
+ finish();
40
+ return value;
41
+ }
42
+ catch (error) {
43
+ finish(true);
44
+ throw error;
45
+ }
46
+ }
47
+ snapshot() {
48
+ const active = [];
49
+ for (const entry of this.#active.values()) {
50
+ active.push({ phase: entry.phase, target: entry.target, startedAtMs: entry.startedAt,
51
+ ageMs: Math.max(0, this.clock() - entry.startedAt) });
52
+ if (active.length === 16)
53
+ break;
54
+ }
55
+ return {
56
+ completed: this.#completed, maximumMs: this.#maximumMs, inFlight: this.#active.size,
57
+ active, slowest: this.#slowest,
58
+ recent: [...this.#recent]
59
+ };
60
+ }
61
+ }
62
+ /** Small worker-response observation; no historical arrays on the RPC wire. */
63
+ export function compactWorkObservation(observer) {
64
+ const { completed, maximumMs, slowest, recent } = observer.snapshot();
65
+ return { completed, maximumMs, slowest, latest: recent.at(-1) ?? null };
66
+ }
11
67
  const CONTROLLER_DELAY_BUCKETS_MS = [10, 50, 100, 250, 500, 1_000, 3_000];
12
68
  const DEFAULT_EVENT_LOOP_DELAY_INTERVAL_MS = 50;
13
69
  const BUILTIN_METHODS = new Set([
@@ -19,6 +75,12 @@ const BUILTIN_METHODS = new Set([
19
75
  "controller.rollback-handover",
20
76
  "controller.handover-state"
21
77
  ]);
78
+ const OBSERVED_METHODS = new Set([...BUILTIN_METHODS,
79
+ "scheduler.signal", "scheduler.scan", "scheduler.configure",
80
+ "runtime.ensure-role-session", "runtime.stop-role-session",
81
+ "capability.search", "capability.describe", "capability.call",
82
+ "schedule.control", "job.definition", "job.start", "job.get", "job.cancel", "job.acknowledge"
83
+ ]);
22
84
  export function isBuiltinControllerMethod(method) {
23
85
  return BUILTIN_METHODS.has(method);
24
86
  }
@@ -47,19 +109,23 @@ export class ControllerCommandObserver {
47
109
  #completed = 0;
48
110
  #failed = 0;
49
111
  #inFlight = 0;
50
- /** Monotonic arrival timestamps of in-flight observations (FIFO). */
51
- #inFlightStarted = [];
112
+ /** Insertion order preserves arrival order; completion removes its own token. */
113
+ #inFlightStarted = new Map();
52
114
  #builtinCompleted = 0;
53
115
  #builtinFailed = 0;
54
116
  #dispatchedCompleted = 0;
55
117
  #dispatchedFailed = 0;
118
+ #observations;
56
119
  constructor(clock = monotonicMilliseconds) {
57
120
  this.#clock = clock;
121
+ this.#observations = new ControllerWorkObserver(clock);
58
122
  }
59
- start() {
123
+ start(method = "unknown", requestId) {
60
124
  this.#received += 1;
61
125
  this.#inFlight += 1;
62
- this.#inFlightStarted.push(this.#clock());
126
+ const token = {};
127
+ this.#inFlightStarted.set(token, this.#clock());
128
+ const finish = this.#observations.start(OBSERVED_METHODS.has(method) ? method : "other", requestId);
63
129
  let completed = false;
64
130
  return {
65
131
  complete: (kind, outcome) => {
@@ -67,7 +133,8 @@ export class ControllerCommandObserver {
67
133
  return;
68
134
  completed = true;
69
135
  this.#inFlight = Math.max(0, this.#inFlight - 1);
70
- this.#inFlightStarted.shift();
136
+ this.#inFlightStarted.delete(token);
137
+ finish(outcome === "failure");
71
138
  this.#completed += 1;
72
139
  if (outcome === "failure")
73
140
  this.#failed += 1;
@@ -87,9 +154,9 @@ export class ControllerCommandObserver {
87
154
  };
88
155
  }
89
156
  snapshot() {
90
- const oldest = this.#inFlightStarted.length === 0
157
+ const oldest = this.#inFlightStarted.size === 0
91
158
  ? null
92
- : Math.max(0, this.#clock() - this.#inFlightStarted[0]);
159
+ : Math.max(0, this.#clock() - this.#inFlightStarted.values().next().value);
93
160
  return {
94
161
  received: this.#received,
95
162
  completed: this.#completed,
@@ -103,7 +170,8 @@ export class ControllerCommandObserver {
103
170
  dispatched: {
104
171
  completed: this.#dispatchedCompleted,
105
172
  failed: this.#dispatchedFailed
106
- }
173
+ },
174
+ observations: this.#observations.snapshot()
107
175
  };
108
176
  }
109
177
  }
@@ -122,6 +190,7 @@ export class ControllerEventLoopDelay {
122
190
  #expectedAt = 0;
123
191
  #samples = 0;
124
192
  #maximumLagMs = 0;
193
+ #maximumObservedAtMs = null;
125
194
  #buckets = new Map(CONTROLLER_DELAY_BUCKETS_MS.map((threshold) => [threshold, 0]));
126
195
  #stopped = false;
127
196
  constructor(clock = monotonicMilliseconds, scheduler = productionTelemetryScheduler, intervalMs = DEFAULT_EVENT_LOOP_DELAY_INTERVAL_MS) {
@@ -142,7 +211,10 @@ export class ControllerEventLoopDelay {
142
211
  this.#expectedAt = this.#clock() + this.#intervalMs;
143
212
  this.#samples += 1;
144
213
  const bounded = Math.ceil(lag);
145
- this.#maximumLagMs = Math.max(this.#maximumLagMs, bounded);
214
+ if (this.#maximumObservedAtMs === null || bounded > this.#maximumLagMs) {
215
+ this.#maximumLagMs = bounded;
216
+ this.#maximumObservedAtMs = this.#clock();
217
+ }
146
218
  for (const threshold of CONTROLLER_DELAY_BUCKETS_MS) {
147
219
  if (bounded <= threshold) {
148
220
  this.#buckets.set(threshold, (this.#buckets.get(threshold) ?? 0) + 1);
@@ -158,6 +230,7 @@ export class ControllerEventLoopDelay {
158
230
  return {
159
231
  samples: this.#samples,
160
232
  maximumLagMs: this.#maximumLagMs,
233
+ maximumObservedAtMs: this.#maximumObservedAtMs,
161
234
  lagBuckets: Object.fromEntries(CONTROLLER_DELAY_BUCKETS_MS.map((threshold) => [
162
235
  `le${threshold}ms`,
163
236
  this.#buckets.get(threshold) ?? 0