@zq-silk/yui 0.6.13 → 0.6.14

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 (90) hide show
  1. package/README.md +34 -5
  2. package/dist/cli/commandCatalog.js +173 -57
  3. package/dist/cli/helpRenderer.js +3 -1
  4. package/dist/cli.js +99 -11
  5. package/dist/commands/configCommands.js +521 -171
  6. package/dist/commands/deliveryGuardPreflight.js +2 -2
  7. package/dist/commands/executionAuditCommands.js +56 -3
  8. package/dist/commands/projectCommands.js +504 -2
  9. package/dist/commands/releaseCommands.js +0 -1
  10. package/dist/commands/taskActor.js +17 -0
  11. package/dist/commands/taskBaseCommands.js +29 -0
  12. package/dist/commands/taskCommands.js +577 -126
  13. package/dist/commands/taskContextCommand.js +36 -2
  14. package/dist/commands/taskNextActionCommand.js +48 -3
  15. package/dist/commands/taskPublicationCommands.js +319 -0
  16. package/dist/commands/taskRoleRuntimeStatus.js +83 -59
  17. package/dist/commands/telemetryCommands.js +14 -13
  18. package/dist/config/yuiConfig.js +161 -8
  19. package/dist/context/sessionContextBudget.js +68 -0
  20. package/dist/context/wakeNotification.js +65 -0
  21. package/dist/controller/clientRuntime.js +2 -1
  22. package/dist/controller/ephemeralResourceReaper.js +2 -1
  23. package/dist/controller/fileSchedulerStoreAdapter.js +183 -16
  24. package/dist/controller/jobSupervisor.js +5 -4
  25. package/dist/controller/resourceCleanupLinux.js +6 -6
  26. package/dist/controller/resourceInventoryLinux.js +3 -3
  27. package/dist/controller/runtime.js +88 -10
  28. package/dist/controller/updateReconciliation.js +4 -3
  29. package/dist/doctor/doctor.js +26 -7
  30. package/dist/executor/agentConfigurationCatalog.js +18 -0
  31. package/dist/executor/fileRoleLaunchPlanner.js +3 -3
  32. package/dist/lifecycle/contextBudgetRollover.js +81 -0
  33. package/dist/lifecycle/exactRunTerminalization.js +11 -1
  34. package/dist/lifecycle/providerErrorClass.js +33 -12
  35. package/dist/observability/executionAudit.js +214 -6
  36. package/dist/output/table.js +18 -0
  37. package/dist/repository/gitWorkspace.js +92 -0
  38. package/dist/repository/project.js +218 -4
  39. package/dist/repository/taskBaseFreshness.js +318 -0
  40. package/dist/repository/taskWorkspacePreparer.js +16 -2
  41. package/dist/review/deltaRecheck.js +232 -0
  42. package/dist/review/reviewConfig.js +31 -0
  43. package/dist/review/reviewFindingLedger.js +5 -1
  44. package/dist/review/reviewRound.js +156 -1
  45. package/dist/run/providerRetry.js +21 -3
  46. package/dist/run/providerRetryConfig.js +13 -60
  47. package/dist/run/recoveryProjection.js +199 -0
  48. package/dist/runtime/builtinAgentDrivers.js +3 -0
  49. package/dist/runtime/builtinTranscriptUsage.js +76 -32
  50. package/dist/runtime/continuationManager.js +17 -0
  51. package/dist/runtime/index.js +2 -0
  52. package/dist/runtime/launchDiagnostics.js +154 -0
  53. package/dist/runtime/lifecycleReservation.js +13 -0
  54. package/dist/runtime/providerContinuation.js +38 -0
  55. package/dist/runtime/providerContinuationReconciliationService.js +1 -0
  56. package/dist/runtime/providerErrorCodes.js +278 -0
  57. package/dist/runtime/runtimeHealthPolicy.js +20 -0
  58. package/dist/runtime/runtimeObservation.js +1 -0
  59. package/dist/runtime/runtimeProjection.js +115 -23
  60. package/dist/runtime/tmuxAdapters.js +242 -48
  61. package/dist/scheduler/activeRoleRunDelivery.js +139 -3
  62. package/dist/scheduler/activeTaskProgress.js +4 -3
  63. package/dist/scheduler/leaderWakeupProcessor.js +105 -15
  64. package/dist/scheduler/roleRunLiveness.js +2 -1
  65. package/dist/scheduler/roleRunStall.js +3 -2
  66. package/dist/scheduler/taskWake.js +72 -0
  67. package/dist/scheduler/wakeReason.js +64 -0
  68. package/dist/scheduler/wakeupQueue.js +2 -1
  69. package/dist/setup/setupCommand.js +1 -1
  70. package/dist/storage/migration/productionRegistry.js +325 -1
  71. package/dist/storage/sqliteSchema.js +61 -2
  72. package/dist/storage/sqliteStore.js +129 -2
  73. package/dist/storage/storeRpc.js +1 -0
  74. package/dist/storage/taskStore.js +262 -5
  75. package/dist/storage/upgrade/recordVersions.js +6 -1
  76. package/dist/storage/upgrade/sqliteStateMigration.js +22 -2
  77. package/dist/task/completionReadiness.js +282 -0
  78. package/dist/task/publicationReference.js +123 -0
  79. package/dist/task/taskRecordReference.js +3 -1
  80. package/dist/telemetry/telemetryConfig.js +23 -18
  81. package/dist/telemetry/telemetryWiring.js +8 -8
  82. package/dist/tmux/tmuxManager.js +50 -9
  83. package/dist/web/assets/client/i18n.js +4 -0
  84. package/dist/web/assets/client/view.js +18 -0
  85. package/dist/web/webSnapshot.js +100 -10
  86. package/i18n/README.zh-CN.md +5 -5
  87. package/package.json +1 -1
  88. package/skills/yui-leader/SKILL.md +49 -10
  89. package/skills/yui-operator/SKILL.md +13 -3
  90. package/skills/yui-worker/SKILL.md +8 -0
@@ -2,7 +2,8 @@ import { isDeepStrictEqual } from "node:util";
2
2
  import { hasRuntimeCleanupObligation, hasRuntimeLifecycleWork, runtimeLifecycleTarget } from "../runtime/lifecycleReservation.js";
3
3
  import { isRoleRunStalled, latestStallProgressAt } from "../scheduler/roleRunStall.js";
4
4
  import { createRuntimeObservation, runtimeObservationFromTaskEvent } from "../runtime/runtimeObservation.js";
5
- import { evaluateRuntimeAttention, projectRuntimeMailbox, projectRuntimeObservation, projectRuntimeTaskEvents, runtimeDisplayStatus } from "../runtime/runtimeProjection.js";
5
+ import { classifyRuntimeHealth, projectRuntimeMailbox, projectRuntimeObservation, projectRuntimeTaskEvents, runtimeDisplayStatus } from "../runtime/runtimeProjection.js";
6
+ import { latestRunDurableProgressAt } from "../scheduler/roleRunStall.js";
6
7
  import { builtinDriverIdForAdapter } from "../runtime/builtinAgentDrivers.js";
7
8
  import { formatAgentRunReceiptId } from "../task/taskRecordReference.js";
8
9
  export function inspectTaskRoleRuntimeStatuses(taskId, roles, store, panes, now = new Date()) {
@@ -20,6 +21,9 @@ export function renderTaskRoleRuntimeStatus(status) {
20
21
  const activeRun = status.activeRun === null
21
22
  ? "-"
22
23
  : `${status.activeRun.id} (${activeRunDeliveryLabel(status.activeRun)})`;
24
+ const lastRun = status.activeRun !== null || status.lastRun === null
25
+ ? undefined
26
+ : `${status.lastRun.id} (${status.lastRun.status}${status.lastRun.endedAt === undefined ? "" : ` at ${status.lastRun.endedAt}`})`;
23
27
  const activeWork = status.activeWork === null
24
28
  ? "-"
25
29
  : `${status.activeWork.id} (${status.activeWork.status}) ${status.activeWork.title}`;
@@ -44,10 +48,12 @@ export function renderTaskRoleRuntimeStatus(status) {
44
48
  ? "not observable"
45
49
  : [
46
50
  `${status.runtime.driverId}: ${status.runtime.status}`,
47
- `attention=${status.runtime.attention}`,
51
+ `health=${status.runtime.healthLayer}`,
52
+ `health reason=${status.runtime.healthReason}`,
48
53
  status.runtime.lastActivityAt === undefined
49
54
  ? undefined
50
55
  : `last activity=${status.runtime.lastActivityAt}`,
56
+ `last semantic progress=${status.runtime.lastSemanticProgressAt}`,
51
57
  status.runtime.activeOperations.length === 0
52
58
  ? undefined
53
59
  : `operations=${status.runtime.activeOperations.join(",")}`,
@@ -73,6 +79,7 @@ export function renderTaskRoleRuntimeStatus(status) {
73
79
  ` Role state ${status.role.status}`,
74
80
  ` Active work ${activeWork}`,
75
81
  ` Active run ${activeRun}`,
82
+ ...(lastRun === undefined ? [] : [` Last run ${lastRun}`]),
76
83
  ` Run attention ${status.stall.active
77
84
  ? `needs-attention (${status.stall.kind ?? "workflow-not-progressing"}; no workflow progress since ${status.stall.progressAt ?? "unknown"})`
78
85
  : "none"}`,
@@ -90,6 +97,14 @@ export function taskRoleActiveWorkLabel(status) {
90
97
  return `${status.activeWork.id}: ${status.activeWork.title}`;
91
98
  return status.activeRun === null ? "-" : status.activeRun.id;
92
99
  }
100
+ /** Issue 09: compact last-Run outcome label for the Role list table. */
101
+ export function taskRoleLastRunLabel(status) {
102
+ if (status.activeRun !== null)
103
+ return `${status.activeRun.id} ${status.activeRun.status}`;
104
+ if (status.lastRun === null)
105
+ return "-";
106
+ return `${status.lastRun.id} ${status.lastRun.status}`;
107
+ }
93
108
  export function taskRoleNativeSessionLabel(status) {
94
109
  if (status.runtimeCleanupPending && status.nativeSession === null)
95
110
  return "reset (cleanup-pending)";
@@ -121,6 +136,13 @@ export function taskRoleTmuxLabel(status) {
121
136
  }
122
137
  function inspectTaskRoleRuntimeStatus(taskId, role, store, pane, openInputRequestCount, now) {
123
138
  const activeRun = store.getActiveAgentRun(taskId, role.name);
139
+ // Issue 09: the last Run outcome is a separate axis from the Session
140
+ // lifecycle. A Session that stops after its Run yielded must not retroactively
141
+ // turn that Run into a failure; the status display keeps both visible.
142
+ const lastRun = store.listAgentRuns(taskId)
143
+ .filter((candidate) => candidate.roleName === role.name)
144
+ .sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt))[0]
145
+ ?? null;
124
146
  const activeWork = activeRun?.workItemId === undefined
125
147
  ? null
126
148
  : store.getWorkItem(taskId, activeRun.workItemId);
@@ -154,7 +176,7 @@ function inspectTaskRoleRuntimeStatus(taskId, role, store, pane, openInputReques
154
176
  ? { managed: false, path: role.workspace }
155
177
  : { ...managedWorkspace, managed: true };
156
178
  const events = store.listEvents(taskId);
157
- const runtime = projectTaskRoleRuntime(activeRun, nativeSession, tmux, events, store.getWorkMailbox({ kind: "role", taskId, roleName: role.name }), now);
179
+ const runtime = projectTaskRoleRuntime(activeRun, nativeSession, tmux, events, store.getWorkMailbox({ kind: "role", taskId, roleName: role.name }), store, taskId, role.name, now);
158
180
  const stalled = activeRun !== null && isRoleRunStalled(events, activeRun.id);
159
181
  const stallProgressAt = activeRun === null
160
182
  ? undefined
@@ -162,7 +184,7 @@ function inspectTaskRoleRuntimeStatus(taskId, role, store, pane, openInputReques
162
184
  const stallKind = activeRun === null
163
185
  ? undefined
164
186
  : latestStallKind(events, activeRun.id);
165
- const health = calculateHealth(role, activeRun, nativeSession, recovery.runtimeCleanupPending, tmux, openInputRequestCount, stalled, runtime);
187
+ const health = calculateHealth(role, activeRun, lastRun, nativeSession, recovery.runtimeCleanupPending, tmux, openInputRequestCount, stalled, runtime);
166
188
  const stall = activeRun === null
167
189
  ? { active: false }
168
190
  : {
@@ -184,6 +206,7 @@ function inspectTaskRoleRuntimeStatus(taskId, role, store, pane, openInputReques
184
206
  openInputRequestCount,
185
207
  role,
186
208
  activeRun,
209
+ lastRun,
187
210
  activeWork,
188
211
  nativeSession,
189
212
  tmux,
@@ -200,7 +223,7 @@ function latestStallKind(events, runId) {
200
223
  ? event.payload.kind
201
224
  : undefined;
202
225
  }
203
- function calculateHealth(role, activeRun, nativeSession, runtimeCleanupPending, tmux, openInputRequestCount, stalled, runtime) {
226
+ function calculateHealth(role, activeRun, lastRun, nativeSession, runtimeCleanupPending, tmux, openInputRequestCount, stalled, runtime) {
204
227
  if (runtimeCleanupPending && nativeSession === null) {
205
228
  return {
206
229
  health: "needs-attention",
@@ -211,7 +234,18 @@ function calculateHealth(role, activeRun, nativeSession, runtimeCleanupPending,
211
234
  return { health: "failed", healthReason: `persisted Role state is ${role.status}` };
212
235
  }
213
236
  if (nativeSession?.status === "broken") {
214
- return { health: "failed", healthReason: "the active native session is broken" };
237
+ // Issue 09: a broken Session only fails a live Run. When the last Run
238
+ // already yielded, the Session death is a lifecycle event, not a Run
239
+ // failure — surface it as attention with the persisted Run outcome.
240
+ if (activeRun !== null) {
241
+ return { health: "failed", healthReason: "the active native session is broken" };
242
+ }
243
+ return {
244
+ health: "needs-attention",
245
+ healthReason: lastRun === null
246
+ ? "the native session is broken"
247
+ : `the native session is broken; last run ${lastRun.id} ${lastRun.status}`
248
+ };
215
249
  }
216
250
  const awaitingProviderAcceptance = activeRun?.pushedAt !== undefined
217
251
  && activeRun.deliveredAt === undefined;
@@ -245,52 +279,36 @@ function calculateHealth(role, activeRun, nativeSession, runtimeCleanupPending,
245
279
  };
246
280
  }
247
281
  if (activeRun.deliveredAt !== undefined) {
248
- if (runtime === null
249
- || runtime.status === "runtime-unobservable"
250
- || runtime.attention === "unobservable") {
251
- return {
252
- health: "needs-attention",
253
- healthReason: "the host is present but the Agent Driver exposes no current runtime state"
254
- };
255
- }
256
- if (runtime.attention === "quiet" || runtime.attention === "active-operation-quiet") {
257
- return {
258
- health: "needs-attention",
259
- healthReason: runtime.attention === "active-operation-quiet"
260
- ? "the Agent Driver reports an open operation but no recent structured runtime activity"
261
- : "the Agent Driver has not reported recent structured runtime activity"
262
- };
263
- }
264
- if (runtime.status === "broken") {
265
- return {
266
- health: "failed",
267
- healthReason: `the Agent Driver runtime is ${runtime.status}`
268
- };
269
- }
270
- if (runtime.status === "stopped") {
282
+ if (runtime === null) {
271
283
  return {
272
284
  health: "needs-attention",
273
- healthReason: "the Provider Activation ended while the Yui Run remains active"
274
- };
275
- }
276
- if (runtime.status.startsWith("waiting-")) {
277
- return {
278
- health: runtime.status === "waiting-user" ? "blocked-input" : "waiting",
279
- healthReason: `the Agent Driver is ${runtime.status.replaceAll("-", " ")}`
285
+ healthReason: "the delivered Run has no authoritative Agent Driver state"
280
286
  };
281
287
  }
282
- if (runtime.status === "ready") {
283
- return {
284
- health: "needs-attention",
285
- healthReason: "the Agent turn ended while the workflow Run is still active"
286
- };
287
- }
288
- if (["model-active", "tool-active", "subagent-active", "active-quiet"]
289
- .includes(runtime.status)) {
290
- return {
291
- health: "running",
292
- healthReason: `the Agent Driver reports ${runtime.status.replaceAll("-", " ")}`
293
- };
288
+ switch (runtime.healthLayer) {
289
+ case "broken":
290
+ return { health: "failed", healthReason: runtime.healthReason };
291
+ case "stopped":
292
+ case "diagnostic-needed":
293
+ case "ready":
294
+ case "awaiting-provider-acceptance":
295
+ case "runtime-unobservable":
296
+ case "starting":
297
+ return { health: "needs-attention", healthReason: runtime.healthReason };
298
+ case "waiting-user":
299
+ return { health: "blocked-input", healthReason: runtime.healthReason };
300
+ case "waiting-permission":
301
+ case "waiting-external":
302
+ return { health: "waiting", healthReason: runtime.healthReason };
303
+ case "quiet":
304
+ case "active-quiet":
305
+ case "model-active":
306
+ case "tool-active":
307
+ case "subagent-active":
308
+ default:
309
+ // Short silence is a hint, not a failure. Only deterministic
310
+ // dead/broken evidence or the durable stall window escalates.
311
+ return { health: "running", healthReason: runtime.healthReason };
294
312
  }
295
313
  }
296
314
  }
@@ -327,7 +345,7 @@ function calculateHealth(role, activeRun, nativeSession, runtimeCleanupPending,
327
345
  ? { health: "ready", healthReason: "the native Agent pane is ready without active work" }
328
346
  : { health: "idle", healthReason: "there is no active work or live tmux pane" };
329
347
  }
330
- function projectTaskRoleRuntime(run, session, tmux, events, mailbox, now) {
348
+ function projectTaskRoleRuntime(run, session, tmux, events, mailbox, store, taskId, roleName, now) {
331
349
  if (run === null || session?.launchId === undefined)
332
350
  return null;
333
351
  let driverId;
@@ -370,21 +388,27 @@ function projectTaskRoleRuntime(run, session, tmux, events, mailbox, now) {
370
388
  fence,
371
389
  payload: { alive: tmux.state === "running" }
372
390
  }));
373
- const attention = evaluateRuntimeAttention(projection, now, {
374
- runtimeSilenceMs: 5 * 60_000,
375
- // Workflow attention has its own durable scheduler policy. This value is
376
- // deliberately not consumed here; keeping it separate prevents token/tool
377
- // activity from extending the workflow deadline.
378
- semanticSilenceMs: 30 * 60_000
391
+ // The semantic progress fence is the same durable fold the scheduler stall
392
+ // pass consumes, so CLI/Web/scheduler share one progress clock.
393
+ const semanticProgress = run.deliveredAt === undefined
394
+ ? { progressAt: run.createdAt }
395
+ : latestRunDurableProgressAt(store, taskId, roleName, run.id)
396
+ ?? { progressAt: run.deliveredAt };
397
+ const classification = classifyRuntimeHealth({
398
+ projection,
399
+ semanticProgressAt: semanticProgress.progressAt,
400
+ now
379
401
  });
380
402
  return {
381
403
  driverId,
382
404
  status: runtimeDisplayStatus(projection),
383
- attention: attention.runtime,
384
- ...(projection.lastRuntimeActivityAt === undefined
405
+ healthLayer: classification.layer,
406
+ healthReason: classification.reason,
407
+ lastSemanticProgressAt: classification.lastSemanticProgressAt,
408
+ ...(classification.lastRuntimeActivityAt === undefined
385
409
  ? {}
386
- : { lastActivityAt: projection.lastRuntimeActivityAt }),
387
- activeOperations: Object.entries(projection.operations).map(([id, operation]) => (`${operation.kind}:${id}`)),
410
+ : { lastActivityAt: classification.lastRuntimeActivityAt }),
411
+ activeOperations: classification.activeOperations,
388
412
  ...(projection.waitingReason === undefined
389
413
  ? {}
390
414
  : { waitingReason: projection.waitingReason }),
@@ -7,6 +7,10 @@ import { COMMITTED_DATABASE_FILENAME } from "../storage/upgrade/sqliteStateMigra
7
7
  import { DEFAULT_RUN_CAP, DEFAULT_TERMINAL_KEEP, resolveRunCap, resolveTelemetryMode, resolveTerminalKeep } from "../telemetry/telemetryConfig.js";
8
8
  import { applyTelemetryCompaction, planTelemetryCompaction } from "../telemetry/telemetryCompaction.js";
9
9
  import { SqliteTelemetryStore } from "../telemetry/sqliteTelemetryStore.js";
10
+ /** Read the durable config, falling back to defaults when no store is available. */
11
+ function storeConfig(options) {
12
+ return options.store?.getConfig() ?? { schemaVersion: 1 };
13
+ }
10
14
  export async function runTelemetryCommand(args, options) {
11
15
  const [command, ...rest] = args;
12
16
  if (command === "status")
@@ -27,12 +31,11 @@ export async function runTelemetryCommand(args, options) {
27
31
  // -- status ---------------------------------------------------------------------
28
32
  function telemetryStatus(args, options) {
29
33
  const flags = parseFlags(args, new Set([]));
30
- const env = options.environment ?? process.env;
31
- const mode = resolveTelemetryMode(env);
34
+ const mode = resolveTelemetryMode(storeConfig(options).telemetryMode);
32
35
  const telemetry = new SqliteTelemetryStore(options.home, {
33
36
  mode,
34
- terminalKeep: resolveTerminalKeep(env),
35
- runCap: resolveRunCap(env)
37
+ terminalKeep: resolveTerminalKeep(storeConfig(options).telemetryTerminalKeep),
38
+ runCap: resolveRunCap(storeConfig(options).telemetryRunCap)
36
39
  });
37
40
  try {
38
41
  const health = telemetry.health();
@@ -52,8 +55,8 @@ function telemetryStatus(args, options) {
52
55
  coalesced: health.coalesced,
53
56
  lastError: health.lastError,
54
57
  totalRows: health.rows,
55
- terminalKeep: resolveTerminalKeep(env),
56
- runCap: resolveRunCap(env),
58
+ terminalKeep: resolveTerminalKeep(storeConfig(options).telemetryTerminalKeep),
59
+ runCap: resolveRunCap(storeConfig(options).telemetryRunCap),
57
60
  tasks: perTask
58
61
  };
59
62
  if (options.json || flags.has("json"))
@@ -84,11 +87,10 @@ function telemetryPrune(args, options) {
84
87
  const taskId = stringOption(args, "--task");
85
88
  const keep = integerOption(args, "--keep", DEFAULT_TERMINAL_KEEP);
86
89
  const dryRun = flags.has("dry-run");
87
- const env = options.environment ?? process.env;
88
- const cap = resolveRunCap(env);
90
+ const cap = resolveRunCap(storeConfig(options).telemetryRunCap);
89
91
  const store = requireStore(options);
90
92
  const telemetry = new SqliteTelemetryStore(options.home, {
91
- mode: resolveTelemetryMode(env),
93
+ mode: resolveTelemetryMode(storeConfig(options).telemetryMode),
92
94
  terminalKeep: keep,
93
95
  runCap: cap
94
96
  });
@@ -237,11 +239,10 @@ function telemetryRead(args, options) {
237
239
  const runId = stringOption(args, "--run");
238
240
  const limit = integerOption(args, "--limit", 100);
239
241
  const offset = integerOption(args, "--offset", 0);
240
- const env = options.environment ?? process.env;
241
242
  const telemetry = new SqliteTelemetryStore(options.home, {
242
- mode: resolveTelemetryMode(env),
243
- terminalKeep: resolveTerminalKeep(env),
244
- runCap: resolveRunCap(env)
243
+ mode: resolveTelemetryMode(storeConfig(options).telemetryMode),
244
+ terminalKeep: resolveTerminalKeep(storeConfig(options).telemetryTerminalKeep),
245
+ runCap: resolveRunCap(storeConfig(options).telemetryRunCap)
245
246
  });
246
247
  try {
247
248
  if (flags.has("aggregate")) {
@@ -1,3 +1,6 @@
1
+ import { supportedAgentAdapterIds } from "../agent/adapterCatalog.js";
2
+ import { PROVIDER_RETRY_MAX_WINDOW_MS } from "../run/providerRetry.js";
3
+ import { DEFAULT_RUN_CAP, DEFAULT_TELEMETRY_MODE, DEFAULT_TERMINAL_KEEP, MAX_RUN_CAP } from "../telemetry/telemetryConfig.js";
1
4
  export const DEFAULT_RECONCILIATION_INTERVAL_SECONDS = 120;
2
5
  export const MIN_RECONCILIATION_INTERVAL_SECONDS = 5;
3
6
  export const MAX_RECONCILIATION_INTERVAL_SECONDS = 300;
@@ -66,15 +69,165 @@ export function resolveLeaderNextActionMode(value) {
66
69
  }
67
70
  return normalized;
68
71
  }
72
+ // ── Issue 01: Provider retry ──────────────────────────────────────────────
73
+ export const PROVIDER_RETRY_MODES = ["off", "shadow", "enforce"];
74
+ export const DEFAULT_PROVIDER_RETRY_MODE = "enforce";
75
+ export function resolveProviderRetryMode(value) {
76
+ if (value === undefined || value === null)
77
+ return DEFAULT_PROVIDER_RETRY_MODE;
78
+ if (typeof value !== "string") {
79
+ throw new TypeError("providerRetryMode must be off, shadow, or enforce.");
80
+ }
81
+ const normalized = value.trim().toLowerCase();
82
+ if (normalized.length === 0)
83
+ return DEFAULT_PROVIDER_RETRY_MODE;
84
+ if (!PROVIDER_RETRY_MODES.includes(normalized)) {
85
+ throw new TypeError("providerRetryMode must be off, shadow, or enforce.");
86
+ }
87
+ return normalized;
88
+ }
89
+ /**
90
+ * Resolves the adapter list. `["all"]` or undefined means every supported
91
+ * adapter; an empty array disables in-place retry.
92
+ */
93
+ export function resolveProviderRetryAdapters(value) {
94
+ if (value === undefined || value === null) {
95
+ return [...supportedAgentAdapterIds()];
96
+ }
97
+ if (!Array.isArray(value)) {
98
+ throw new TypeError("providerRetryAdapters must be an array of adapter ids.");
99
+ }
100
+ const supported = new Set(supportedAgentAdapterIds());
101
+ const adapters = [];
102
+ for (const raw of value) {
103
+ if (typeof raw !== "string") {
104
+ throw new TypeError("providerRetryAdapters entries must be strings.");
105
+ }
106
+ const token = raw.trim().toLowerCase();
107
+ if (token === "all") {
108
+ for (const adapter of supportedAgentAdapterIds()) {
109
+ if (!adapters.includes(adapter))
110
+ adapters.push(adapter);
111
+ }
112
+ continue;
113
+ }
114
+ if (!/^[a-z0-9][a-z0-9._-]*$/u.test(token)) {
115
+ throw new TypeError(`Invalid Provider retry adapter: ${token}.`);
116
+ }
117
+ if (!supported.has(token)) {
118
+ throw new TypeError(`Unknown Provider retry adapter: ${token}.`);
119
+ }
120
+ if (!adapters.includes(token))
121
+ adapters.push(token);
122
+ }
123
+ return adapters;
124
+ }
125
+ export function resolveProviderRetryMaxWindowMs(value) {
126
+ if (value === undefined || value === null)
127
+ return PROVIDER_RETRY_MAX_WINDOW_MS;
128
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) {
129
+ throw new TypeError("providerRetryMaxWindowMs must be a positive integer.");
130
+ }
131
+ return value;
132
+ }
133
+ export function resolveYieldReceiptReplay(value) {
134
+ if (value === undefined || value === null)
135
+ return true;
136
+ if (typeof value !== "boolean") {
137
+ throw new TypeError("yieldReceiptReplay must be a boolean.");
138
+ }
139
+ return value;
140
+ }
141
+ // ── Executable paths ──────────────────────────────────────────────────────
142
+ export function resolveTmuxBin(value) {
143
+ if (value === undefined || value === null)
144
+ return "tmux";
145
+ if (typeof value !== "string" || value.trim().length === 0) {
146
+ throw new TypeError("tmuxBin must be a non-empty string.");
147
+ }
148
+ return value.trim();
149
+ }
150
+ export function resolveGitBin(value) {
151
+ if (value === undefined || value === null)
152
+ return "git";
153
+ if (typeof value !== "string" || value.trim().length === 0) {
154
+ throw new TypeError("gitBin must be a non-empty string.");
155
+ }
156
+ return value.trim();
157
+ }
158
+ // ── Telemetry ─────────────────────────────────────────────────────────────
159
+ const TELEMETRY_MODES = ["legacy", "dual", "bounded"];
160
+ export function resolveTelemetryMode(value) {
161
+ if (value === undefined || value === null)
162
+ return DEFAULT_TELEMETRY_MODE;
163
+ if (typeof value !== "string") {
164
+ throw new TypeError("telemetryMode must be legacy, dual, or bounded.");
165
+ }
166
+ const normalized = value.trim().toLowerCase();
167
+ if (normalized.length === 0)
168
+ return DEFAULT_TELEMETRY_MODE;
169
+ if (!TELEMETRY_MODES.includes(normalized)) {
170
+ throw new TypeError(`telemetryMode must be one of ${TELEMETRY_MODES.join(", ")}; got ${JSON.stringify(normalized)}.`);
171
+ }
172
+ return normalized;
173
+ }
174
+ export function resolveTelemetryTerminalKeep(value) {
175
+ if (value === undefined || value === null)
176
+ return DEFAULT_TERMINAL_KEEP;
177
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1) {
178
+ throw new TypeError("telemetryTerminalKeep must be a positive integer.");
179
+ }
180
+ return value;
181
+ }
182
+ export function resolveTelemetryRunCap(value) {
183
+ if (value === undefined || value === null)
184
+ return DEFAULT_RUN_CAP;
185
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1) {
186
+ throw new TypeError("telemetryRunCap must be a positive integer.");
187
+ }
188
+ if (value > MAX_RUN_CAP) {
189
+ throw new TypeError(`telemetryRunCap must not exceed ${MAX_RUN_CAP.toLocaleString("en-US")} (retention cannot be disabled).`);
190
+ }
191
+ return value;
192
+ }
69
193
  /**
70
- * Resolve the effective mode. An explicit environment override
71
- * (`YUI_LEADER_NEXT_ACTION_MODE`) wins over the durable config value so a
72
- * single CLI invocation can be tightened or loosened without a config write.
194
+ * Issue 04 (context token budget): thresholds for one native Session
195
+ * generation's observed per-request input peak, measured in tokens. When the
196
+ * peak crosses the soft threshold the Leader wake carries a checkpoint
197
+ * advisory; when it crosses the hard threshold the scheduler retires the
198
+ * generation and starts a fresh one instead of waiting for provider-side
199
+ * auto-compaction. The fields are additive and optional — Homes without them
200
+ * keep these defaults, so no config migration is required.
73
201
  */
74
- export function leaderNextActionMode(configured, env = process.env) {
75
- const override = env.YUI_LEADER_NEXT_ACTION_MODE;
76
- if (override !== undefined && override.trim().length > 0) {
77
- return resolveLeaderNextActionMode(override);
202
+ export const DEFAULT_CONTEXT_SOFT_TOKENS = 100_000;
203
+ export const DEFAULT_CONTEXT_HARD_TOKENS = 120_000;
204
+ export const MIN_CONTEXT_BUDGET_TOKENS = 1_000;
205
+ export const MAX_CONTEXT_BUDGET_TOKENS = 1_000_000;
206
+ function resolveContextBudgetToken(value, label) {
207
+ if (typeof value !== "number" || !Number.isSafeInteger(value)) {
208
+ throw new TypeError(`${label} must be a safe integer.`);
209
+ }
210
+ if (value < MIN_CONTEXT_BUDGET_TOKENS || value > MAX_CONTEXT_BUDGET_TOKENS) {
211
+ throw new TypeError(`${label} must be between ${MIN_CONTEXT_BUDGET_TOKENS} and ${MAX_CONTEXT_BUDGET_TOKENS}.`);
212
+ }
213
+ return value;
214
+ }
215
+ export function resolveContextBudget(configured) {
216
+ if (configured === undefined || configured === null) {
217
+ return { softTokens: DEFAULT_CONTEXT_SOFT_TOKENS, hardTokens: DEFAULT_CONTEXT_HARD_TOKENS };
218
+ }
219
+ if (typeof configured !== "object" || Array.isArray(configured)) {
220
+ throw new TypeError("contextBudget must be an object.");
221
+ }
222
+ const record = configured;
223
+ const softTokens = record.softTokens === undefined
224
+ ? DEFAULT_CONTEXT_SOFT_TOKENS
225
+ : resolveContextBudgetToken(record.softTokens, "contextBudget.softTokens");
226
+ const hardTokens = record.hardTokens === undefined
227
+ ? DEFAULT_CONTEXT_HARD_TOKENS
228
+ : resolveContextBudgetToken(record.hardTokens, "contextBudget.hardTokens");
229
+ if (softTokens >= hardTokens) {
230
+ throw new TypeError("contextBudget.softTokens must be smaller than contextBudget.hardTokens.");
78
231
  }
79
- return resolveLeaderNextActionMode(configured);
232
+ return { softTokens, hardTokens };
80
233
  }
@@ -0,0 +1,68 @@
1
+ import { resolveContextBudget } from "../config/yuiConfig.js";
2
+ import { runtimeObservationFromTaskEvent } from "../runtime/runtimeObservation.js";
3
+ export function evaluateSessionContextBudget(events, session, configured) {
4
+ const budget = resolveContextBudget(configured);
5
+ const peak = observedPeakInputTokens(events, session);
6
+ const state = peak >= budget.hardTokens
7
+ ? "hard"
8
+ : peak >= budget.softTokens
9
+ ? "soft"
10
+ : "within";
11
+ return Object.freeze({ state, peakTokens: peak, budget });
12
+ }
13
+ /**
14
+ * Largest per-request input peak observed for one Session generation. Usage
15
+ * observations carry cumulative session totals; consecutive deltas bound the
16
+ * per-request input of the window between them. The first observation for a
17
+ * generation is treated as one full request so a long-lived resumed Session
18
+ * that was only sampled late still counts its inherited context.
19
+ */
20
+ export function observedPeakInputTokens(events, session) {
21
+ let previous = 0;
22
+ let peak = 0;
23
+ for (const event of events) {
24
+ const observation = runtimeObservationFromTaskEvent(event);
25
+ if (observation === null)
26
+ continue;
27
+ if (!matchesSession(observation.fence, session))
28
+ continue;
29
+ const usage = usageTotal(observation.payload.usage);
30
+ if (usage === null)
31
+ continue;
32
+ const delta = Math.max(0, usage - previous);
33
+ if (delta > peak)
34
+ peak = delta;
35
+ previous = usage;
36
+ }
37
+ return peak;
38
+ }
39
+ function matchesSession(fence, session) {
40
+ if (fence.taskId !== undefined && fence.taskId !== session.taskId)
41
+ return false;
42
+ if (fence.roleName !== session.roleName)
43
+ return false;
44
+ if (session.nativeSessionId !== undefined
45
+ && fence.nativeSessionId !== undefined
46
+ && fence.nativeSessionId !== session.nativeSessionId) {
47
+ return false;
48
+ }
49
+ if (session.launchId !== undefined
50
+ && fence.launchId !== undefined
51
+ && fence.launchId !== session.launchId) {
52
+ return false;
53
+ }
54
+ return true;
55
+ }
56
+ function usageTotal(usage) {
57
+ if (typeof usage !== "object" || usage === null || Array.isArray(usage))
58
+ return null;
59
+ const record = usage;
60
+ const inputTokens = integer(record.inputTokens);
61
+ if (inputTokens === null)
62
+ return null;
63
+ const cachedInputTokens = integer(record.cachedInputTokens) ?? 0;
64
+ return inputTokens + cachedInputTokens;
65
+ }
66
+ function integer(value) {
67
+ return Number.isSafeInteger(value) && value >= 0 ? value : null;
68
+ }
@@ -0,0 +1,65 @@
1
+ import { renderWakeReason } from "../scheduler/wakeReason.js";
2
+ /**
3
+ * Issue 04 (context token budget) — long-term design:
4
+ *
5
+ * A Leader wake is a NOTIFICATION, not a context dump. The wake envelope
6
+ * carries only what the Agent cannot reconstruct from durable records:
7
+ * the wake id, the aggregated reason tags, and the delta window. The Agent
8
+ * reads the delta content on demand with `yui task wake show <wake-id>` and
9
+ * the full projection with `yui task context <task>`.
10
+ *
11
+ * The envelope is mode-agnostic: fresh generations and resumed generations
12
+ * receive the same minimal text. The native Session is a disposable cache of
13
+ * working context; Yui's durable Task records (including the wake ledger) are
14
+ * the checkpoint.
15
+ */
16
+ /** Structural guardrail: the envelope must stay an order of magnitude below any context budget. */
17
+ export const WAKE_ENVELOPE_HARD_BYTES = 2_000;
18
+ /** Maximum reason tags rendered before elision to a count. */
19
+ const REASON_DISPLAY_LIMIT = 6;
20
+ export function buildTaskWakeEnvelope(reader, request) {
21
+ const task = reader.getTask(request.taskId);
22
+ if (task === null)
23
+ throw new Error(`Task not found: ${request.taskId}.`);
24
+ if (request.reasons.length === 0) {
25
+ throw new Error(`Wake envelope ${request.wakeId} must carry at least one reason.`);
26
+ }
27
+ const fromTime = Date.parse(request.fromCursor);
28
+ const counts = {
29
+ events: reader.listEvents(request.taskId)
30
+ .filter((record) => Date.parse(record.createdAt) > fromTime).length,
31
+ messages: reader.listMessages(request.taskId)
32
+ .filter((record) => Date.parse(record.createdAt) > fromTime).length,
33
+ runs: reader.listAgentRuns(request.taskId)
34
+ .filter((record) => Date.parse(record.createdAt) > fromTime).length
35
+ };
36
+ const lines = [
37
+ `Wake: ${request.wakeId} — delta since ${request.fromCursor}`,
38
+ ` Reasons: ${renderReasons(request.reasons)}`,
39
+ ` Changed: ${counts.events} events, ${counts.messages} messages, ${counts.runs} runs`
40
+ + ` → yui task wake show ${request.taskId} ${request.wakeId}`,
41
+ `Full context: yui task context ${request.taskId}`
42
+ ];
43
+ const body = lines.join("\n");
44
+ const totalBytes = byteLength(body) + 1;
45
+ if (totalBytes > WAKE_ENVELOPE_HARD_BYTES) {
46
+ throw new Error(`Wake envelope ${request.wakeId} exceeds the structural hard budget of`
47
+ + ` ${WAKE_ENVELOPE_HARD_BYTES} bytes (${totalBytes}).`);
48
+ }
49
+ return Object.freeze({
50
+ taskId: request.taskId,
51
+ wakeId: request.wakeId,
52
+ fromCursor: request.fromCursor,
53
+ totalBytes,
54
+ text: `${body}\n`
55
+ });
56
+ }
57
+ function renderReasons(reasons) {
58
+ const selected = reasons.slice(0, REASON_DISPLAY_LIMIT);
59
+ const elided = reasons.length - selected.length;
60
+ const rendered = selected.map(renderWakeReason).join(", ");
61
+ return elided === 0 ? rendered : `${rendered}, … (+${elided} more)`;
62
+ }
63
+ function byteLength(value) {
64
+ return Buffer.byteLength(value, "utf8");
65
+ }
@@ -20,7 +20,6 @@ const ENVIRONMENT_REFRESH_TIMEOUT_MS = 500;
20
20
  const CONFIGURATION_REFRESH_TIMEOUT_MS = 500;
21
21
  const CONTROLLER_OPERATIONAL_ENVIRONMENT = [
22
22
  ...AGENT_OPERATIONAL_ENVIRONMENT_NAMES,
23
- "YUI_TMUX_BIN",
24
23
  // Issue 01: YUI_STORE_BACKEND/YUI_STORE_WORKER are reserved for tests and
25
24
  // explicit recovery commands. A Controller spawned by such a command must
26
25
  // inherit the forced backend; otherwise a layout-7 test Home silently opens
@@ -30,6 +29,8 @@ const CONTROLLER_OPERATIONAL_ENVIRONMENT = [
30
29
  // rr13/test: Forward the liveness seam so an integration test's Controller
31
30
  // subprocess does not reap a saved active Leader Run without a real tmux role.
32
31
  "YUI_TEST_ROLE_LIVENESS_PRESENT",
32
+ // Issue 02: agent-signal-driven launch monitoring is Controller-owned.
33
+ "YUI_LAUNCH_INACTIVITY_TIMEOUT_MS",
33
34
  ...EPHEMERAL_DOMAIN_ENVIRONMENT_NAMES
34
35
  ];
35
36
  /**
@@ -84,7 +84,8 @@ export function createEphemeralResourceReaper(options) {
84
84
  return () => reapExpiredEphemeralResources({
85
85
  scan,
86
86
  clean: (resource) => cleanControllerResource(resource, {
87
- environment: options.environment
87
+ environment: options.environment,
88
+ tmuxBin: options.tmuxBin
88
89
  }),
89
90
  onExpiredDomain: options.onExpiredDomain
90
91
  });