@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
@@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url";
6
6
  import { isDeepStrictEqual } from "node:util";
7
7
  import { runPurposeAdmitsTaskState } from "../agentRun/agentRun.js";
8
8
  import { assertExecutionEnvironmentCurrent } from "../runtime/executionEnvironment.js";
9
+ import { assertRoleEnabled } from "../role/availability.js";
9
10
  import { configuredAgentToDefinition, resolveAgentEnvironment } from "../agent/agent.js";
10
11
  import { NATIVE_AGENT_ENVIRONMENT_NAMES, nativeAgentEnvironmentNames, operationalAgentEnvironment, selectEnvironment } from "../agent/launchEnvironment.js";
11
12
  import { compileRoleSessionContext, roleSessionKind } from "../context/roleSessionContext.js";
@@ -106,6 +107,7 @@ export class FileRoleLaunchPlanner {
106
107
  const role = this.store.getRole(input.taskId, input.roleName);
107
108
  if (role === null)
108
109
  throw new Error(`Role not found: ${input.taskId}/${input.roleName}.`);
110
+ assertRoleEnabled(role);
109
111
  const activeRun = this.store.getActiveRun(task.id, role.name);
110
112
  if (input.runId !== undefined && activeRun?.id !== input.runId) {
111
113
  throw new Error(`Role AgentRun is no longer current: ${input.runId}.`);
@@ -226,6 +228,7 @@ export class FileRoleLaunchPlanner {
226
228
  const role = this.store.getGlobalRole(input.roleName);
227
229
  if (role === null)
228
230
  throw new Error(`Global Role not found: ${input.roleName}.`);
231
+ assertRoleEnabled(role);
229
232
  const sessionSet = this.store.getGlobalRoleSessionSet(role.name);
230
233
  if (activeLiveRoleAgentSession(sessionSet) !== null && sessionSet?.providerBinding == null) {
231
234
  throw new Error("Global Role has an unmanaged live Session. Stop that exact Session before opening controlled input; it will not be silently replaced.");
@@ -0,0 +1,51 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdirSync, writeFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { requireKnownFields } from "../domain/validation.js";
5
+ import { validateJobExecutionConfiguration } from "./durableJob.js";
6
+ export function validateJobScriptFiles(files) {
7
+ if (!Array.isArray(files) || files.length > 64)
8
+ throw new Error("Job supports at most 64 script files.");
9
+ const paths = new Set();
10
+ let bytes = 0;
11
+ for (const file of files) {
12
+ requireKnownFields(file, ["path", "content"], "Job script file");
13
+ if (typeof file.path !== "string" || file.path.length > 240
14
+ || !/^[a-zA-Z0-9_-][a-zA-Z0-9._/-]*$/u.test(file.path)
15
+ || file.path.split("/").some((part) => !part || part === "." || part === "..")) {
16
+ throw new Error("Script paths must be safe relative paths, for example scripts/check.sh.");
17
+ }
18
+ if (paths.has(file.path) || [...paths].some(path => path.startsWith(file.path + "/") || file.path.startsWith(path + "/"))) {
19
+ throw new Error("Script paths must be unique and cannot overlap directories.");
20
+ }
21
+ if (typeof file.content !== "string" || file.content.includes("\0"))
22
+ throw new Error("Scripts must be UTF-8 text without NUL.");
23
+ bytes += Buffer.byteLength(file.content);
24
+ if (bytes > 512 * 1024)
25
+ throw new Error("Job scripts exceed 512 KiB.");
26
+ paths.add(file.path);
27
+ }
28
+ }
29
+ export function prepareGlobalShell(configuration, workspace) {
30
+ requireKnownFields(configuration, ["kind", "env", "steps", "files"], "Global Shell Job");
31
+ if (configuration.kind !== "shell")
32
+ throw new Error("Not a Shell Job.");
33
+ validateJobScriptFiles(configuration.files);
34
+ // No repository applies. This hash identifies executable configuration,
35
+ // never a Project commit or an Integration candidate.
36
+ const head = createHash("sha1").update(JSON.stringify(configuration)).digest("hex");
37
+ const spec = { workspace, env: configuration.env, steps: configuration.steps, head };
38
+ validateJobExecutionConfiguration(spec);
39
+ return spec;
40
+ }
41
+ /** A fresh per-Run directory; never overwrite another run's files or follow
42
+ * a pre-existing workspace. The runner's exclusive ready receipt fences replay. */
43
+ export function materializeJobScripts(workspace, files) {
44
+ validateJobScriptFiles(files);
45
+ mkdirSync(workspace, { mode: 0o700 });
46
+ for (const file of files) {
47
+ const path = join(workspace, file.path);
48
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
49
+ writeFileSync(path, file.content, { flag: "wx", mode: 0o700 });
50
+ }
51
+ }
@@ -62,9 +62,10 @@ export function jobRunSnapshot(run) {
62
62
  ...runReference(run), definitionId: run.definitionId, definitionRevision: run.definitionRevision,
63
63
  owner: "owner" in run ? run.owner : null,
64
64
  trigger: run.trigger, retryOf: run.retryOf ?? null,
65
- executor: run.delegation ? { kind: "task", delegation: run.delegation } : "taskId" in run ? { kind: "shell" } : {
66
- kind: "builtin", action: run.action, input: run.actionInput
67
- },
65
+ executor: run.delegation ? { kind: "task", delegation: run.delegation } : "taskId" in run ? { kind: "shell" }
66
+ : run.action === "shell.exec" ? { kind: "shell", configuration: run.actionInput } : {
67
+ kind: "builtin", action: run.action, input: run.actionInput
68
+ },
68
69
  target: run.delegation ? { taskId: run.delegation.taskId, workItemId: run.delegation.workItemId ?? null }
69
70
  : { projectId: run.projectId ?? null, workspace: run.workspace, head: run.head },
70
71
  execution: run.delegation ? null : { env: run.env, steps: run.steps, implementation: run.operation.implementation }
@@ -2,6 +2,7 @@ import { requireIdentity, requireKnownFields, requirePositiveInteger, requireTex
2
2
  import { validateJobExecutionConfiguration, validateDurableJobOwner } from "./durableJob.js";
3
3
  import { validateAgentJobConfiguration } from "./agentJob.js";
4
4
  import { validateTaskJobConfiguration } from "./taskDelegation.js";
5
+ import { prepareGlobalShell } from "./globalShell.js";
5
6
  export function jobScopeKey(scope) {
6
7
  if (scope.kind === "global")
7
8
  return "global";
@@ -44,6 +45,10 @@ export function validateJobDefinition(job) {
44
45
  validateTaskJobConfiguration(config);
45
46
  }
46
47
  else if (job.scope.kind === "global") {
48
+ if (config.kind === "shell") {
49
+ prepareGlobalShell(config, "/job-validation");
50
+ return job;
51
+ }
47
52
  if (config.kind !== "builtin")
48
53
  throw new Error("Global Jobs require a controlled builtin action.");
49
54
  requireKnownFields(config, ["kind", "action", "projectId", "input"], "Builtin Job");
@@ -54,7 +59,7 @@ export function validateJobDefinition(job) {
54
59
  throw new Error("Builtin Job input is required.");
55
60
  }
56
61
  else {
57
- if (config.kind !== "shell")
62
+ if (config.kind !== "shell" || !("owner" in config))
58
63
  throw new Error("Task Jobs require a managed shell configuration.");
59
64
  requireKnownFields(config, ["kind", "owner", "projectId", "head", "workspace", "env", "steps"], "Task Job");
60
65
  requireIdentity(config.projectId, "Job Project id");
@@ -13,6 +13,7 @@ import { join, resolve } from "node:path";
13
13
  import { fileURLToPath } from "node:url";
14
14
  import { jobStepDirectory } from "./stepDirectory.js";
15
15
  import { prepareHttpPreview, isHttpPreview } from "./httpPreview.js";
16
+ import { materializeJobScripts, validateJobScriptFiles } from "./globalShell.js";
16
17
  import { writeTextFileAtomically } from "../storage/durableFile.js";
17
18
  import { currentProcessStartIdentity } from "../core/fileLockOwner.js";
18
19
  const DEFAULT_STEP_TIMEOUT_MS = 30 * 60_000;
@@ -112,6 +113,8 @@ export async function runDurableJobRunner(specPath) {
112
113
  let failedStep;
113
114
  let preview;
114
115
  try {
116
+ if (spec.files !== undefined)
117
+ materializeJobScripts(spec.workspace, spec.files);
115
118
  for (let index = 0; index < spec.steps.length; index += 1) {
116
119
  if (cancelled || existsSync(cancelPath)) {
117
120
  cancelled = true;
@@ -278,6 +281,12 @@ function readSpec(specPath) {
278
281
  if (typeof spec.artifactDir !== "string" || !spec.artifactDir.startsWith("/")) {
279
282
  throw new Error("DurableJob spec artifactDir must be an absolute path.");
280
283
  }
284
+ if (spec.files !== undefined) {
285
+ if (spec.scope !== "global" || spec.workspace !== join(spec.artifactDir, "workspace")) {
286
+ throw new Error("Job script snapshots require their own global Run workspace.");
287
+ }
288
+ validateJobScriptFiles(spec.files);
289
+ }
281
290
  if (typeof spec.head !== "string" || spec.head.length === 0) {
282
291
  throw new Error("DurableJob spec is missing head.");
283
292
  }
@@ -4,6 +4,8 @@ import { requireProjectSyncTarget } from "./projectSync.js";
4
4
  /** Internal compiled registration, not a caller identity accepted by RPC. */
5
5
  const actions = new Set(["system.noop", "project.refresh"]);
6
6
  export function requireServiceTarget(job) {
7
+ if (job.scope.kind === "global" && job.configuration.kind === "shell")
8
+ return;
7
9
  if (job.scope.kind !== "global" || job.configuration.kind !== "builtin"
8
10
  || !actions.has(job.configuration.action))
9
11
  throw new Error("Schedule service is not registered for this scope/action.");
@@ -4,28 +4,39 @@ import { hasRuntimeCleanupObligation, runtimeLifecycleTarget } from "../runtime/
4
4
  import { currentProviderConversation } from "../runtime/providerRuntimeIdentity.js";
5
5
  import { INBOX_CHECK_PREFIX } from "./inboxTurn.js";
6
6
  const requestId = (agentId, nativeSessionId) => `${INBOX_CHECK_PREFIX}recovery:${createHash("sha256").update(JSON.stringify([agentId, nativeSessionId])).digest("hex")}`;
7
+ function recoveryCandidate(store, roleName) {
8
+ const sessions = store.getGlobalRoleSessionSet(roleName);
9
+ const session = sessions?.sessions[sessions.activeAgentId];
10
+ const binding = sessions?.providerBinding;
11
+ if (!session || session.status !== "active" || !session.nativeSessionId || !binding
12
+ || binding.authority.owner !== "controller"
13
+ || currentProviderConversation(binding).conversationId !== session.nativeSessionId
14
+ || binding.run && ["submitting", "accepted", "delivery-unknown"].includes(binding.run.status)
15
+ || hasRuntimeCleanupObligation(store.getWorkMailbox(runtimeLifecycleTarget({ scope: "global", roleName }))))
16
+ return;
17
+ if (store.queryGlobalRoleMessages(roleName, { handling: true, handlingExceptSession: {
18
+ agentId: session.agentId, nativeSessionId: session.nativeSessionId
19
+ }, limit: 0 }).count === 0)
20
+ return;
21
+ const id = requestId(session.agentId, session.nativeSessionId);
22
+ return { id, agentId: session.agentId, nativeSessionId: session.nativeSessionId,
23
+ existing: store.queryGlobalRoleMessages(roleName, { requestId: id, limit: 1 }).messages[0] };
24
+ }
7
25
  /** Derive a single initialization notice from existing retained claims. New
8
26
  * Sessions are created only by the existing authorized lifecycle path. A
9
27
  * restart can finish notice creation, but cannot select or replace a Session. */
10
28
  export function globalInboxRecovery(store, roleName, now) {
29
+ // The common idle/already-notified path must not reserve SQLite's writer.
30
+ // This snapshot is only a preflight; creation rechecks all authority, cleanup
31
+ // and retained-claim facts in the original atomic transaction.
32
+ const candidate = store.readTransaction(tx => recoveryCandidate(tx, roleName));
33
+ if (!candidate || candidate.existing)
34
+ return candidate?.existing;
11
35
  return store.transaction(tx => {
12
- const sessions = tx.getGlobalRoleSessionSet(roleName);
13
- const session = sessions?.sessions[sessions.activeAgentId];
14
- const binding = sessions?.providerBinding;
15
- if (!session || session.status !== "active" || !session.nativeSessionId || !binding
16
- || binding.authority.owner !== "controller"
17
- || currentProviderConversation(binding).conversationId !== session.nativeSessionId
18
- || binding.run && ["submitting", "accepted", "delivery-unknown"].includes(binding.run.status)
19
- || hasRuntimeCleanupObligation(tx.getWorkMailbox(runtimeLifecycleTarget({ scope: "global", roleName }))))
20
- return;
21
- if (tx.queryGlobalRoleMessages(roleName, { handling: true, handlingExceptSession: {
22
- agentId: session.agentId, nativeSessionId: session.nativeSessionId
23
- }, limit: 0 }).count === 0)
24
- return;
25
- const id = requestId(session.agentId, session.nativeSessionId);
26
- const existing = tx.queryGlobalRoleMessages(roleName, { requestId: id, limit: 1 }).messages[0];
27
- if (existing)
28
- return existing;
36
+ const latest = recoveryCandidate(tx, roleName);
37
+ if (!latest || latest.existing)
38
+ return latest?.existing;
39
+ const { id, agentId, nativeSessionId } = latest;
29
40
  const notice = {
30
41
  ...createGlobalRoleMessage(tx.nextGlobalRoleMessageId(), roleName, [
31
42
  "Yui-Summary: This successor has retained inbox handling obligations; inspect original evidence before settlement.",
@@ -34,9 +45,9 @@ export function globalInboxRecovery(store, roleName, now) {
34
45
  "After handling under current authority, explicitly acknowledge the original entry without replacing its claim.",
35
46
  "Uncertain external effects require verification or a real user decision. New queued input remains separate."
36
47
  ].join("\n"), "system", { type: "system" }, now, {
37
- inputControl: { action: "queue", requestId: id, expectedSessionId: session.nativeSessionId }
48
+ inputControl: { action: "queue", requestId: id, expectedSessionId: nativeSessionId }
38
49
  }),
39
- deliveryTarget: { agentId: session.agentId, nativeSessionId: session.nativeSessionId }
50
+ deliveryTarget: { agentId, nativeSessionId }
40
51
  };
41
52
  tx.saveGlobalRoleMessage(notice);
42
53
  return notice;
@@ -2,6 +2,12 @@ import { markGlobalRoleMessageNotDelivered } from "./message.js";
2
2
  /** Settle the original queue entry when recovery ends with exact nondelivery.
3
3
  * Cancellation of a submitted/unknown request is not negative Provider proof. */
4
4
  export function settleGlobalRetryInput(store, roleName, binding, now) {
5
+ const settlement = globalRetryInputSettlement(store, roleName, binding, now);
6
+ if (settlement !== undefined)
7
+ store.updateGlobalRoleMessage(settlement);
8
+ }
9
+ /** Read-only proposal; callers applying it must recheck under their transaction. */
10
+ export function globalRetryInputSettlement(store, roleName, binding, now) {
5
11
  const retry = binding?.retry;
6
12
  if (retry === undefined || retry.input.kind !== "message" || retry.input.roleName !== roleName
7
13
  || !["cancelled", "exhausted", "needs-attention"].includes(retry.status)
@@ -11,5 +17,5 @@ export function settleGlobalRetryInput(store, roleName, binding, now) {
11
17
  const message = store.listGlobalRoleMessages(roleName).find(m => m.id === messageId);
12
18
  if (message === undefined || message.delivery !== undefined || message.notDelivered !== undefined)
13
19
  return;
14
- store.updateGlobalRoleMessage(markGlobalRoleMessageNotDelivered(message, `provider-retry-${retry.status}: ${retry.reason ?? retry.failureRef}`, now));
20
+ return markGlobalRoleMessageNotDelivered(message, `provider-retry-${retry.status}: ${retry.reason ?? retry.failureRef}`, now);
15
21
  }
@@ -1,4 +1,5 @@
1
1
  import { isDeepStrictEqual } from "node:util";
2
+ import { isEnabled } from "../role/availability.js";
2
3
  import { createRun } from "../agentRun/agentRun.js";
3
4
  import { createRunInput } from "../context/runInputContract.js";
4
5
  import { contextContentDigest, contextSnapshotRef, createContextSnapshot } from "../context/contextSnapshot.js";
@@ -90,6 +91,9 @@ export function messageAssignmentBlocker(store, message) {
90
91
  }
91
92
  /** Starting another Run additionally requires the reviewed candidate to remain current. */
92
93
  export function messageContinuationBlocker(store, message) {
94
+ const role = message.recipient === undefined ? null : store.getRole(message.taskId, message.recipient.roleName);
95
+ if (role !== null && !isEnabled(role))
96
+ return "role-disabled";
93
97
  const invalid = messageAssignmentBlocker(store, message);
94
98
  if (invalid !== undefined)
95
99
  return invalid;
@@ -1,3 +1,4 @@
1
+ import { isEnabled, roleDisabledReason } from "../role/availability.js";
1
2
  import { activeRoleAgentSession } from "../executor/agentExecutor.js";
2
3
  import { effectiveRoleForLaunch } from "../executor/effectiveLaunch.js";
3
4
  import { defaultTableWidth, renderTable } from "./table.js";
@@ -49,6 +50,8 @@ export function renderRoleDetails(title, role, input) {
49
50
  ];
50
51
  const overview = [
51
52
  ` Kind ${input.kind}`,
53
+ ` Enabled ${isEnabled(role)}`,
54
+ ...(!isEnabled(role) ? [` Availability ${roleDisabledReason(role)}`] : []),
52
55
  ` Active Agent ${role.activeAgentId}`,
53
56
  ` Workspace ${role.workspace}`,
54
57
  ...("taskId" in role ? [
@@ -1,3 +1,4 @@
1
+ import { validateEnabled } from "../role/availability.js";
1
2
  import { cloneJson, normalizedUniqueText, optionalText, requireIdentity, requirePositiveInteger, requireTimestamp } from "../domain/validation.js";
2
3
  export const BUILTIN_PROFILE_IDS = Object.freeze([
3
4
  "worker",
@@ -31,6 +32,7 @@ export function updateAgentProfile(profile, patch, now) {
31
32
  return validateAgentProfile(candidate);
32
33
  }
33
34
  export function validateAgentProfile(profile) {
35
+ validateEnabled(profile.enabled);
34
36
  if (profile.schemaVersion !== 1) {
35
37
  throw new Error("AgentProfile must use schemaVersion 1.");
36
38
  }
@@ -82,7 +84,9 @@ export function builtinAgentProfileInputs() {
82
84
  ];
83
85
  }
84
86
  function normalizeInput(input) {
87
+ validateEnabled(input.enabled);
85
88
  return {
89
+ enabled: input.enabled ?? true,
86
90
  defaultAccess: validatedAccess(input.defaultAccess ?? "read"),
87
91
  ...(input.description === undefined
88
92
  ? {}
@@ -98,6 +102,10 @@ function normalizeInput(input) {
98
102
  }
99
103
  function normalizePatch(patch) {
100
104
  const result = {};
105
+ if (patch.enabled !== undefined) {
106
+ validateEnabled(patch.enabled);
107
+ result.enabled = patch.enabled;
108
+ }
101
109
  if (Object.hasOwn(patch, "defaultAccess")) {
102
110
  result.defaultAccess = validatedAccess(patch.defaultAccess);
103
111
  }
@@ -1,3 +1,4 @@
1
+ import { isEnabled, roleDisabledReason } from "../role/availability.js";
1
2
  import { createRoleAgentBinding } from "../role/role.js";
2
3
  /** The single read model for persisted Profile intent and current runtime. */
3
4
  export function resolveAgentProfileView(profile, store) {
@@ -13,7 +14,12 @@ export function resolveAgentProfileView(profile, store) {
13
14
  * dormant, and overlays its own model/effort; another Agent starts from its
14
15
  * provider defaults.
15
16
  */
16
- export function resolveAgentProfileRuntime(profile, store) {
17
+ export function resolveAgentProfileRuntime(profile, store, options = {}) {
18
+ if (!options.configurationOnly && !isEnabled(profile))
19
+ return {
20
+ status: "unavailable", source: profile.runtime.source,
21
+ reason: `Agent Profile ${profile.id} is disabled. Use yui config profile enable ${profile.id}. Existing Task bindings are unchanged.`
22
+ };
17
23
  if (profile.runtime.source === "global-worker") {
18
24
  const worker = store.getGlobalRole("worker");
19
25
  if (worker === null) {
@@ -23,6 +29,11 @@ export function resolveAgentProfileRuntime(profile, store) {
23
29
  reason: "Global Role worker is not configured."
24
30
  };
25
31
  }
32
+ if (!options.configurationOnly && !isEnabled(worker))
33
+ return {
34
+ status: "unavailable", source: "global-worker", workerRevision: worker.launchRevision,
35
+ reason: roleDisabledReason(worker)
36
+ };
26
37
  const binding = worker.agentBindings[worker.activeAgentId];
27
38
  if (binding === undefined) {
28
39
  return {
@@ -66,6 +77,10 @@ export function resolveAgentProfileRuntime(profile, store) {
66
77
  }
67
78
  const worker = store.getGlobalRole("worker");
68
79
  const workerBinding = worker?.agentBindings[agent.id];
80
+ if (!options.configurationOnly && worker !== null && workerBinding !== undefined && !isEnabled(worker)) {
81
+ return { status: "unavailable", source: "explicit",
82
+ reason: `Profile copies the matching Worker binding. ${roleDisabledReason(worker)}` };
83
+ }
69
84
  if (workerBinding !== undefined && workerBinding.adapterId !== agent.adapterId) {
70
85
  return {
71
86
  status: "unavailable",
@@ -1,4 +1,5 @@
1
1
  import { isDeepStrictEqual } from "node:util";
2
+ import { isEnabled, roleDisabledReason } from "../role/availability.js";
2
3
  import { isCompletedTaskReviewEvidence } from "./reviewAcceptance.js";
3
4
  import { projectReviewerAvailability } from "./reviewerAvailability.js";
4
5
  /** Read-only facts for Leader judgment; this projection never blocks an action. */
@@ -43,6 +44,11 @@ export function projectReviewDecision(input) {
43
44
  ])].sort();
44
45
  const reviewers = reviewerNames.map((reviewerRoleName) => {
45
46
  const role = roles.find(({ name }) => name === reviewerRoleName);
47
+ const applicable = role ?? store.getGlobalRole(reviewerRoleName);
48
+ if (applicable !== null && !isEnabled(applicable))
49
+ return {
50
+ reviewerRoleName, status: "unavailable", retryable: false, reason: roleDisabledReason(applicable)
51
+ };
46
52
  if (role === undefined && store.getGlobalRole(reviewerRoleName) === null) {
47
53
  return {
48
54
  reviewerRoleName,
@@ -0,0 +1,28 @@
1
+ /** Additive configuration: valid older records remain enabled. This is not
2
+ * Session state, launch configuration drift, or permission to stop a writer. */
3
+ export function isEnabled(value) {
4
+ return value.enabled !== false;
5
+ }
6
+ export function validateEnabled(value) {
7
+ if (value !== undefined && typeof value !== "boolean") {
8
+ throw new Error("Enabled must be a boolean.");
9
+ }
10
+ }
11
+ export function parseEnabled(value) {
12
+ if (value !== "true" && value !== "false")
13
+ throw new Error("--enabled must be true or false.");
14
+ return value === "true";
15
+ }
16
+ export function roleDisabledReason(role) {
17
+ const target = role.taskId === undefined ? `config role enable ${role.name}`
18
+ : `task role enable ${role.taskId} ${role.name}`;
19
+ return `Role ${role.taskId === undefined ? "" : `${role.taskId}/`}${role.name} is disabled. `
20
+ + `Use yui ${target} to allow new execution; existing execution is not stopped.`;
21
+ }
22
+ export class RoleDisabledError extends Error {
23
+ name = "RoleDisabledError";
24
+ }
25
+ export function assertRoleEnabled(role) {
26
+ if (!isEnabled(role))
27
+ throw new RoleDisabledError(roleDisabledReason(role));
28
+ }
package/dist/role/role.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { isDeepStrictEqual } from "node:util";
2
+ import { assertRoleEnabled, validateEnabled } from "./availability.js";
2
3
  import { requireKnownFields } from "../domain/validation.js";
3
4
  import { jobScopeKey } from "../job/jobDefinition.js";
4
5
  import { validateExecutionEnvironmentSnapshot } from "../resources/projectResource.js";
@@ -44,10 +45,12 @@ export function createGlobalRole(name, bindings, activeAgentId, workspace, now,
44
45
  }
45
46
  export function copyGlobalRoleToTaskRole(globalRole, taskId, now, name = globalRole.name) {
46
47
  validateGlobalRole(globalRole);
48
+ assertRoleEnabled(globalRole);
47
49
  const timestamp = now.toISOString();
48
50
  return validateTaskRole({
49
51
  ...cloneProfile(globalRole),
50
52
  schemaVersion: 1,
53
+ enabled: true,
51
54
  launchRevision: 1,
52
55
  defaultAccess: globalRole.defaultAccess,
53
56
  taskId: requireSafeIdentity(taskId, "Task id"),
@@ -219,6 +222,7 @@ function createRoleOwner(name, bindings, activeAgentId, workspace, now, profile,
219
222
  const timestamp = now.toISOString();
220
223
  return {
221
224
  ...cloneProfile(profile),
225
+ enabled: true,
222
226
  launchRevision: 1,
223
227
  defaultAccess,
224
228
  name: requireSafeIdentity(name, "Role name"),
@@ -230,6 +234,7 @@ function createRoleOwner(name, bindings, activeAgentId, workspace, now, profile,
230
234
  };
231
235
  }
232
236
  function validateRoleOwner(role) {
237
+ validateEnabled(role.enabled);
233
238
  if (role.schemaVersion !== 1) {
234
239
  throw new Error("Role schema version is invalid.");
235
240
  }
@@ -298,6 +303,7 @@ function assertSessionOwnerMatchesRole(role, sessions) {
298
303
  }
299
304
  function cloneRolePatch(patch) {
300
305
  return {
306
+ ...(patch.enabled === undefined ? {} : { enabled: patch.enabled }),
301
307
  ...(patch.name === undefined ? {} : { name: requireSafeIdentity(patch.name, "Role name") }),
302
308
  ...(patch.activeAgentId === undefined
303
309
  ? {}
@@ -30,6 +30,10 @@ export function mapCodexAgentError(input) {
30
30
  }
31
31
  function mapCodexDisplayError(input) {
32
32
  const text = `${input.message}\n${input.raw}`;
33
+ if (/Codex Session availability is systemError; no input was submitted\./u.test(text)) {
34
+ return { category: "session", code: "provider.session-system-error",
35
+ inputDisposition: "not-accepted", sessionDisposition: "unknown" };
36
+ }
33
37
  if (/^server_error$/iu.test(input.message)) {
34
38
  return recoverable("availability", "provider.server-error");
35
39
  }
@@ -18,13 +18,23 @@ export function createCodexNativeAccess(threadId, initialized, channel, openingM
18
18
  const listeners = new Set();
19
19
  let configuration = initialConfiguration;
20
20
  let observedAt = new Date().toISOString();
21
- const observed = () => typeof configuration?.model !== "string"
22
- ? unknownAgentRunConfiguration("No confirmed native model response. Reconnect to restore the fixed launch configuration.")
21
+ // A start/resume response confirms configuration at that instant, not the
22
+ // current Turn or later changes made by another client. Keep its timestamp;
23
+ // consumers must not describe repeated reads as fresh native observations.
24
+ const observed = () => typeof configuration?.model !== "string" || !configuration.model.trim()
25
+ ? unknownAgentRunConfiguration("No confirmed native configuration response. Current model and reasoning effort are unknown.")
23
26
  : {
24
27
  status: "observed", observedAt,
25
28
  handshake: { status: "unsupported", reason: "Model reported by Codex thread/start or thread/resume; not a capability handshake." },
26
29
  requested: [],
27
- axes: [{ key: "model", category: "model", current: { status: "observed", value: configuration.model }, offered: [] }]
30
+ axes: [
31
+ { key: "model", category: "model", current: { status: "observed", value: configuration.model }, offered: [] },
32
+ { key: "reasoningEffort", category: "effort",
33
+ current: typeof configuration.reasoningEffort === "string" && configuration.reasoningEffort.trim()
34
+ ? { status: "observed", value: configuration.reasoningEffort }
35
+ : { status: "unobserved", reason: "The native response did not confirm reasoning effort." },
36
+ offered: [] }
37
+ ]
28
38
  };
29
39
  const observe = (message) => {
30
40
  const params = message.params;
@@ -86,9 +96,16 @@ export function createCodexNativeAccess(threadId, initialized, channel, openingM
86
96
  if (params.threadId !== threadId)
87
97
  throw new Error("Select the existing Session; new/forked Threads are not supported here.");
88
98
  const page = params.initialTurnsPage;
89
- configuration = await channel.request("thread/resume", { threadId,
99
+ // A failed/uncertain resume cannot leave the earlier confirmation
100
+ // looking valid. Do not retry this potentially applied operation.
101
+ configuration = undefined;
102
+ const result = await channel.request("thread/resume", { threadId,
90
103
  ...(typeof params.excludeTurns === "boolean" ? { excludeTurns: params.excludeTurns } : {}),
91
104
  ...(page ? { initialTurnsPage: { ...page, limit: Math.min(40, typeof page.limit === "number" && page.limit > 0 ? page.limit : 40) } } : {}) });
105
+ if (result.thread?.id !== threadId) {
106
+ throw new Error("Native resume did not confirm the selected Thread.");
107
+ }
108
+ configuration = result;
92
109
  observedAt = new Date().toISOString();
93
110
  return configuration;
94
111
  }
@@ -0,0 +1,39 @@
1
+ import { currentProviderConversation } from "./providerRuntimeIdentity.js";
2
+ import { providerRetrySummary } from "./providerRetry.js";
3
+ /** Read-only provenance. Writer selection is not live Host/transport health. */
4
+ export function globalSessionHealth(role, sessions) {
5
+ const session = sessions?.sessions[role.activeAgentId];
6
+ const binding = sessions?.providerBinding;
7
+ const matches = session !== undefined && binding != null
8
+ && sessions?.activeAgentId === role.activeAgentId
9
+ && currentProviderConversation(binding).conversationId === session.nativeSessionId;
10
+ // Conversation replacement retains the terminal Turn as audit evidence while
11
+ // advancing writer authority. Matching the new conversation alone is insufficient.
12
+ const turnMatches = matches && binding.run?.authorityEpoch === binding.authority.epoch;
13
+ const turn = turnMatches ? binding.run : null;
14
+ const retry = matches && binding.retry?.nativeSessionId === session?.nativeSessionId
15
+ && binding.retry?.authorityEpoch === binding.authority.epoch
16
+ ? providerRetrySummary(binding) : null;
17
+ const failure = turn?.status === "failed" || turn?.status === "rejected" ? turn.failure : undefined;
18
+ return {
19
+ desiredRevision: role.launchRevision,
20
+ effectiveRevision: session?.effective.sourceDesiredRevision ?? null,
21
+ configurationDrift: session === undefined ? null
22
+ : session.effective.sourceDesiredRevision !== role.launchRevision,
23
+ nativeSessionId: session?.nativeSessionId ?? null,
24
+ nativeTurn: turn == null ? null : {
25
+ attemptId: turn.attemptId, nativeTurnId: turn.nativeTurnId, status: turn.status
26
+ },
27
+ currentFailure: failure === undefined ? null : {
28
+ ref: failure.ref, source: failure.error.source, phase: failure.error.phase,
29
+ category: failure.error.category, code: failure.error.code,
30
+ inputDisposition: failure.error.inputDisposition,
31
+ sessionDisposition: failure.error.sessionDisposition
32
+ },
33
+ retry,
34
+ historicalBinding: binding != null && (!matches || (binding.run !== null && !turnMatches)),
35
+ hostObservation: "unobserved",
36
+ note: "Selected/active writer state is not delivery or terminal-observation health. "
37
+ + "Inspect exact Host capabilities and runtime diagnostics; Controller restart does not refresh a live Host."
38
+ };
39
+ }
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { RoleDisabledError } from "../role/availability.js";
2
3
  import { resolve } from "node:path";
3
4
  import { fileURLToPath } from "node:url";
4
5
  import { providerDeliveryFailureFrom } from "./agentError.js";
@@ -189,6 +190,8 @@ export class TmuxSessionHost {
189
190
  : this.planner.planGlobalRole(input);
190
191
  }
191
192
  catch (error) {
193
+ if (error instanceof RoleDisabledError)
194
+ throw error;
192
195
  throw toRuntimeLaunchFailure(error, "validation", {
193
196
  cwd: request.workspace,
194
197
  agentId: request.agentId
@@ -206,6 +209,8 @@ export class TmuxSessionHost {
206
209
  await this.#validateLaunch(request);
207
210
  }
208
211
  catch (error) {
212
+ if (error instanceof RoleDisabledError)
213
+ throw error;
209
214
  throw toRuntimeLaunchFailure(error, "validation", launchContext);
210
215
  }
211
216
  }
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { isEnabled, roleDisabledReason, RoleDisabledError } from "../role/availability.js";
2
3
  import { serializeRunInputEnvelope } from "../context/runInputContract.js";
3
4
  import { readRunContextSnapshot } from "../context/runContextPack.js";
4
5
  import { roleSessionMayContinue, sameEffectiveLaunch } from "../executor/effectiveLaunch.js";
@@ -37,6 +38,8 @@ export async function processActiveRoleRunDeliveries(store, delivery, now, selec
37
38
  }
38
39
  async function deliverActiveRun(store, delivery, task, role, run, now) {
39
40
  const base = { taskId: task.id, roleName: role.name, runId: run.id };
41
+ if (!isEnabled(role))
42
+ return { ...base, status: "skipped", reason: "not-ready", error: roleDisabledReason(role) };
40
43
  if (!isSchedulerTaskWorkspaceReady(task, store.getTaskWorkspace(task.id), run.purpose)) {
41
44
  return { ...base, status: "skipped", reason: "workspace-not-ready" };
42
45
  }
@@ -231,6 +234,12 @@ async function deliverActiveRun(store, delivery, task, role, run, now) {
231
234
  }
232
235
  catch (error) {
233
236
  const message = error instanceof Error ? error.message : String(error);
237
+ // A configuration pause before submission preserves the original intent.
238
+ // After submission, the existing unknown-effect fence still owns recovery.
239
+ if (!submitted && error instanceof RoleDisabledError) {
240
+ forget(delivery, task.id, role.name, run.id);
241
+ return { ...base, status: "skipped", reason: "not-ready", error: message };
242
+ }
234
243
  // Preserve the structured cause alongside the readable diagnosis.
235
244
  const causeName = innermostCauseName(error);
236
245
  store.recordAgentError({
@@ -1,4 +1,5 @@
1
1
  import { roleSessionMayContinue } from "../executor/effectiveLaunch.js";
2
+ import { isEnabled, roleDisabledReason, RoleDisabledError } from "../role/availability.js";
2
3
  import { roleAgentSessionResumeMode } from "../executor/agentExecutor.js";
3
4
  import { isSchedulerTaskWorkspaceReady } from "./ports.js";
4
5
  import { hasImmediateWakeReason } from "./wakeReason.js";
@@ -22,6 +23,10 @@ export async function processLeaderWakeups(store, delivery, now, selection) {
22
23
  results.push({ ...base, status: "skipped", reason: "unavailable" });
23
24
  continue;
24
25
  }
26
+ if (!isEnabled(role)) {
27
+ results.push({ ...base, status: "skipped", reason: "unavailable", error: roleDisabledReason(role) });
28
+ continue;
29
+ }
25
30
  if (!isSchedulerTaskWorkspaceReady(task, store.getTaskWorkspace(task.id), task.status === "draft" ? "planning" : "execution")) {
26
31
  results.push({ ...base, status: "skipped", reason: "workspace-not-ready" });
27
32
  continue;
@@ -104,9 +109,11 @@ export async function processLeaderWakeups(store, delivery, now, selection) {
104
109
  // permits another automatic launch. A config/auth/executable failure
105
110
  // needs correction; an exception after submit cannot authorize replay.
106
111
  const retryable = error instanceof RuntimeLifecycleBusyError
112
+ || error instanceof RoleDisabledError
107
113
  || (error instanceof RuntimeLaunchError && error.retryable);
108
114
  store.settleLeaderNotification(task.id, notification.attemptId, attempted ? "unknown" : retryable ? "deferred" : "rejected", now, detail, { effective, raw: detail, phase: attempted ? "turn-submit" : "session-start" });
109
- results.push({ ...base, status: "failed", reason: "not-ready", error: detail });
115
+ results.push({ ...base, status: !attempted && error instanceof RoleDisabledError ? "skipped" : "failed",
116
+ reason: "not-ready", error: detail });
110
117
  }
111
118
  finally {
112
119
  delivery.forgetPrepared?.({ taskId: task.id, roleName: role.name });