@zq-silk/yui 0.7.1 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/ARCHITECTURE.md +27 -28
  2. package/README.md +79 -71
  3. package/dist/cli/commandCatalog.js +283 -136
  4. package/dist/cli/completion.js +3 -3
  5. package/dist/cli/helpRenderer.js +3 -0
  6. package/dist/cli/interactionPolicy.js +48 -33
  7. package/dist/cli/interactiveSelection.js +1 -1
  8. package/dist/cli/invocationRouter.js +3 -2
  9. package/dist/cli/roleWizard.js +8 -8
  10. package/dist/cli.js +189 -93
  11. package/dist/commands/agentCommands.js +5 -5
  12. package/dist/commands/configCommands.js +351 -104
  13. package/dist/commands/configOverview.js +60 -0
  14. package/dist/commands/deliveryGuardPreflight.js +2 -2
  15. package/dist/commands/globalRoleCommands.js +9 -9
  16. package/dist/commands/profileCommands.js +8 -8
  17. package/dist/commands/resourcesCommands.js +6 -5
  18. package/dist/commands/taskCommands.js +111 -59
  19. package/dist/commands/taskRoleRuntimeStatus.js +3 -1
  20. package/dist/commands/telemetryCommands.js +11 -6
  21. package/dist/config/configCatalog.js +42 -0
  22. package/dist/config/yuiConfig.js +80 -35
  23. package/dist/context/sessionBootstrapManifest.js +1 -1
  24. package/dist/controller/clientRuntime.js +0 -2
  25. package/dist/controller/controller.js +21 -9
  26. package/dist/controller/fileSchedulerStoreAdapter.js +409 -79
  27. package/dist/controller/resourceInventory.js +9 -5
  28. package/dist/controller/runtime.js +112 -25
  29. package/dist/controller/runtimeLaunchCoordinator.js +18 -78
  30. package/dist/controller/structuredProviderObservation.js +273 -0
  31. package/dist/doctor/doctor.js +2 -2
  32. package/dist/executor/agentAdapter.js +40 -0
  33. package/dist/executor/agentExecutor.js +31 -7
  34. package/dist/executor/executorRegistry.js +11 -49
  35. package/dist/executor/fileRoleLaunchPlanner.js +115 -37
  36. package/dist/lifecycle/canonicalLifecycleEvent.js +5 -2
  37. package/dist/resources/autoResourceGc.js +3 -1
  38. package/dist/review/reviewConfig.js +0 -2
  39. package/dist/run/agentRun.js +2 -2
  40. package/dist/run/providerRetry.js +29 -16
  41. package/dist/run/providerRetryConfig.js +5 -3
  42. package/dist/runtime/agentHost.js +767 -158
  43. package/dist/runtime/builtinAgentDrivers.js +1 -5
  44. package/dist/runtime/codexAppServerRuntime.js +67 -60
  45. package/dist/runtime/exactControlPlane.js +7 -2
  46. package/dist/runtime/index.js +6 -2
  47. package/dist/runtime/launchBroker.js +30 -8
  48. package/dist/runtime/launchDiagnostics.js +1 -1
  49. package/dist/runtime/providerAuthorityFence.js +24 -0
  50. package/dist/runtime/providerControl.js +63 -0
  51. package/dist/runtime/providerRecoveryDecision.js +55 -0
  52. package/dist/runtime/providerRuntimeIdentity.js +269 -19
  53. package/dist/runtime/runtimeBinding.js +20 -11
  54. package/dist/runtime/structuredProviderHost.js +476 -0
  55. package/dist/runtime/tmuxAdapters.js +143 -42
  56. package/dist/scheduler/activeRoleRunDelivery.js +206 -120
  57. package/dist/scheduler/leaderWakeupProcessor.js +141 -16
  58. package/dist/scheduler/roleRunStall.js +12 -9
  59. package/dist/setup/setupCommand.js +153 -492
  60. package/dist/storage/compatibleTaskStore.js +9 -5
  61. package/dist/storage/migration/productionRegistry.js +169 -0
  62. package/dist/storage/taskStore.js +22 -3
  63. package/dist/telemetry/sqliteTelemetryStore.js +9 -1
  64. package/dist/telemetry/telemetryConfig.js +1 -18
  65. package/dist/telemetry/telemetryStore.js +2 -2
  66. package/dist/telemetry/telemetryWiring.js +6 -5
  67. package/dist/tmux/tmuxManager.js +1 -1
  68. package/dist/web/webSnapshot.js +5 -3
  69. package/i18n/README.zh-CN.md +48 -40
  70. package/package.json +1 -1
  71. package/skills/yui-leader/SKILL.md +12 -5
  72. package/skills/yui-operator/SKILL.md +44 -6
  73. package/skills/yui-runtime/SKILL.md +1 -1
@@ -24,6 +24,7 @@ import { parseTaskRuntimeIsolationDescriptor, taskRuntimeIsolationEnvironment }
24
24
  import { ResourceRegistrar } from "../resources/resourceRegistrar.js";
25
25
  import { builtinAgentDriverRegistry, builtinDriverIdForAdapter } from "../runtime/builtinAgentDrivers.js";
26
26
  import { managedRuntimeAdmission } from "../runtime/agentDriver.js";
27
+ import { formatAgentRunReceiptId } from "../task/taskRecordReference.js";
27
28
  /** Builds managed native Agent launches from the authoritative Task records. */
28
29
  export class FileRoleLaunchPlanner {
29
30
  home;
@@ -223,7 +224,7 @@ export class FileRoleLaunchPlanner {
223
224
  if (input.mode === "resume" && !compatibleExisting) {
224
225
  throw new Error(`Task Role resume effective snapshot drifted: ${task.id}/${role.name}.`);
225
226
  }
226
- return this.#compile(role, input, { scope: "task", taskId: task.id }, taskRoleSessionTitle(task, role.name), compatibleExisting ? existing.nativeSessionId : undefined, runWorkspace, effective, {
227
+ return this.#compile(role, input, { scope: "task", taskId: task.id }, taskRoleSessionTitle(task, role.name), input.mode === "resume" && compatibleExisting ? existing.nativeSessionId : undefined, runWorkspace, effective, {
227
228
  purpose: activeRun?.purpose ?? "execution"
228
229
  });
229
230
  }
@@ -331,6 +332,9 @@ export class FileRoleLaunchPlanner {
331
332
  const managedRun = owner.scope === "task" && input.runId !== undefined
332
333
  ? this.store.getAgentRun(owner.taskId, input.runId)
333
334
  : null;
335
+ const managedSessionSet = owner.scope === "task" && input.runId !== undefined
336
+ ? this.store.getTaskRoleSessionSet(owner.taskId, role.name)
337
+ : null;
334
338
  const driver = builtinAgentDriverRegistry().require(builtinDriverIdForAdapter(configured.adapterId));
335
339
  if (owner.scope === "task" && input.runId !== undefined) {
336
340
  const admission = managedRuntimeAdmission(driver.capabilities);
@@ -369,12 +373,33 @@ export class FileRoleLaunchPlanner {
369
373
  const launchMode = resumeNativeSessionId === undefined
370
374
  ? "new"
371
375
  : "resume";
372
- const compiled = launchMode === "resume"
373
- ? adapter.compileResume({
374
- ...compileInput,
375
- nativeSessionId: resumeNativeSessionId
376
- })
377
- : adapter.compileNew(compileInput);
376
+ const managedControl = owner.scope === "task" && input.runId !== undefined;
377
+ const managedProviderEnvironment = managedControl
378
+ && configured.adapterId === "codex"
379
+ ? {
380
+ // Managed Codex Runs are non-interactive. Use the Codex execution
381
+ // identity for provider requests while clientInfo still identifies Yui.
382
+ CODEX_INTERNAL_ORIGINATOR_OVERRIDE: "codex_exec"
383
+ }
384
+ : {};
385
+ const preallocatedManagedNativeSessionId = managedControl
386
+ && binding.adapterId === "claude"
387
+ && resumeNativeSessionId === undefined
388
+ ? requireText(input.launchId === undefined
389
+ ? this.#createNativeSessionId()
390
+ : nativeSessionIdForLaunch(this.home, input.launchId, input.agentId, input.adapterId), "Native session id")
391
+ : resumeNativeSessionId;
392
+ const managedCompiled = managedControl
393
+ ? adapter.compileManagedControl(compileInput, launchMode, preallocatedManagedNativeSessionId)
394
+ : undefined;
395
+ const compiled = managedCompiled !== undefined
396
+ ? managedCompiled
397
+ : launchMode === "resume"
398
+ ? adapter.compileResume({
399
+ ...compileInput,
400
+ nativeSessionId: resumeNativeSessionId
401
+ })
402
+ : adapter.compileNew(compileInput);
378
403
  for (const path of [
379
404
  bootstrap.manifestPath,
380
405
  bootstrap.sessionCliPath,
@@ -397,39 +422,32 @@ export class FileRoleLaunchPlanner {
397
422
  if (owner.scope !== "task" || input.runId === undefined) {
398
423
  args = addCodexSessionNotify(args, launchMode, this.#cliPath);
399
424
  }
400
- // A fresh Codex TUI has no provider event before its first prompt. Carry
401
- // the exact Run input as the provider's positional launch prompt so it is
402
- // submitted only after Codex completes startup; never race terminal bytes
403
- // and Enter against TUI initialization.
425
+ // Managed Codex runs use App Server lifecycle hooks and structured Turn
426
+ // submission. No Run prompt is placed in argv or written as terminal input.
404
427
  if (owner.scope === "task" && input.runId !== undefined) {
405
428
  if (managedRun === null || managedRun.status !== "active") {
406
429
  throw new Error(`Managed Codex Run is no longer active: ${input.runId}.`);
407
430
  }
408
- args = addCodexLifecycleHooks(args, launchMode, this.#cliPath);
409
- // End option parsing before the opaque prompt so a wakeup beginning
410
- // with '-' can never be reinterpreted as a Codex CLI flag.
411
- if (managedRun.pushedAt === undefined) {
412
- args.push("--", managedRunLaunchEnvelope(managedRun, input.mode));
413
- }
431
+ args = managedControl
432
+ ? addCodexManagedLifecycleHooks(args, this.#cliPath)
433
+ : addCodexLifecycleHooks(args, launchMode, this.#cliPath);
414
434
  }
415
435
  session = launchMode === "resume"
416
436
  ? readySession(input.agentId, binding.adapterId, resumeNativeSessionId, effective)
417
437
  : null;
418
438
  }
419
439
  else if (launchMode === "new") {
420
- if (owner.scope === "task" && input.runId !== undefined) {
421
- args.push("-p", "--output-format", "stream-json", "--input-format", "stream-json", "--verbose");
440
+ if (managedControl)
422
441
  args.push("--plugin-dir", ensureManagedClaudeLifecyclePlugin(this.home, this.#cliPath));
423
- }
424
- const nativeSessionId = requireText(input.launchId === undefined
425
- ? this.#createNativeSessionId()
426
- : nativeSessionIdForLaunch(this.home, input.launchId, input.agentId, input.adapterId), "Native session id");
427
- args.push("--session-id", nativeSessionId);
442
+ const nativeSessionId = requireText(preallocatedManagedNativeSessionId, "Native session id");
443
+ if (!managedControl)
444
+ args.push("--session-id", nativeSessionId);
445
+ else if (!args.includes("--session-id"))
446
+ args.push("--session-id", nativeSessionId);
428
447
  session = readySession(input.agentId, binding.adapterId, nativeSessionId, effective);
429
448
  }
430
449
  else {
431
- if (owner.scope === "task" && input.runId !== undefined) {
432
- args.push("-p", "--output-format", "stream-json", "--input-format", "stream-json", "--verbose");
450
+ if (managedControl) {
433
451
  args.push("--plugin-dir", ensureManagedClaudeLifecyclePlugin(this.home, this.#cliPath));
434
452
  }
435
453
  session = readySession(input.agentId, binding.adapterId, resumeNativeSessionId, effective);
@@ -469,6 +487,36 @@ export class FileRoleLaunchPlanner {
469
487
  if (owner.scope === "task" && (input.mode === "new" || input.mode === "resume")) {
470
488
  jobCallerKey = randomBytes(32).toString("hex");
471
489
  }
490
+ const carriesInitialTurn = managedControl
491
+ && managedRun?.pushedAt === undefined
492
+ && (managedSessionSet?.providerBinding === null
493
+ || managedSessionSet?.providerBinding === undefined);
494
+ const providerAuthority = managedControl
495
+ ? this.#providerAuthorityForLaunch(owner.taskId, role.name, input.launchId)
496
+ : undefined;
497
+ const providerNativeSessionId = binding.adapterId === "claude"
498
+ ? preallocatedManagedNativeSessionId
499
+ : resumeNativeSessionId;
500
+ const providerControl = managedControl
501
+ ? {
502
+ schemaVersion: 1,
503
+ adapterId: binding.adapterId,
504
+ transport: managedCompiled.transport,
505
+ mode: resumeNativeSessionId === undefined ? "new" : "resume",
506
+ ...(providerNativeSessionId === undefined
507
+ ? {}
508
+ : { nativeSessionId: providerNativeSessionId }),
509
+ authority: providerAuthority,
510
+ ...(carriesInitialTurn
511
+ ? {
512
+ initialTurn: {
513
+ attemptId: formatAgentRunReceiptId(owner.taskId, input.runId),
514
+ boundedText: managedRunLaunchEnvelope(managedRun, input.mode)
515
+ }
516
+ }
517
+ : {})
518
+ }
519
+ : undefined;
472
520
  const launch = {
473
521
  command,
474
522
  args,
@@ -476,16 +524,10 @@ export class FileRoleLaunchPlanner {
476
524
  && managedRun.providerRetry.state !== "dispatching"
477
525
  ? { deferProviderStart: true }
478
526
  : {}),
479
- ...(managedClaudeRun
480
- ? {
481
- providerInput: {
482
- kind: "stdin-json-user-message",
483
- boundedText: managedRunLaunchEnvelope(managedRun, input.mode)
484
- }
485
- }
486
- : {}),
527
+ ...(providerControl === undefined ? {} : { providerControl }),
487
528
  env: {
488
529
  ...launchEnvironment,
530
+ ...managedProviderEnvironment,
489
531
  YUI_HOME: resolve(this.home),
490
532
  YUI_SESSION_SCOPE: owner.scope,
491
533
  ...(owner.scope === "task" ? { YUI_TASK_ID: owner.taskId } : {}),
@@ -536,12 +578,36 @@ export class FileRoleLaunchPlanner {
536
578
  },
537
579
  launch: scopedLaunch,
538
580
  session,
539
- ...((binding.adapterId === "codex" && managedRun?.pushedAt === undefined
540
- || managedClaudeRun) && input.runId !== undefined
541
- ? { initialPromptRunId: input.runId }
581
+ ...(carriesInitialTurn && input.runId !== undefined
582
+ ? { initialTurnRunId: input.runId }
542
583
  : {})
543
584
  };
544
585
  }
586
+ #providerAuthorityForLaunch(taskId, roleName, launchId) {
587
+ const activationId = requireText(launchId, "Managed Provider Activation id");
588
+ const binding = this.store.getTaskRoleSessionSet(taskId, roleName)?.providerBinding;
589
+ if (binding === null || binding === undefined) {
590
+ return { epoch: 1, owner: "controller", holderId: activationId };
591
+ }
592
+ if (binding.authority.owner === "controller") {
593
+ return {
594
+ epoch: binding.authority.epoch,
595
+ owner: "controller",
596
+ holderId: binding.authority.holderId
597
+ };
598
+ }
599
+ if (binding.authority.owner === "human") {
600
+ throw new Error(`Provider writer is held by a human: ${taskId}/${roleName}.`);
601
+ }
602
+ if (binding.authority.owner === "none") {
603
+ return {
604
+ epoch: binding.authority.epoch + 1,
605
+ owner: "controller",
606
+ holderId: activationId
607
+ };
608
+ }
609
+ throw new Error(`Provider writer authority is unknown: ${taskId}/${roleName}.`);
610
+ }
545
611
  #applyWorkspaceScope(taskId, role, launch, workspaceOverride) {
546
612
  const workspace = workspaceOverride
547
613
  ?? (role.name === "leader"
@@ -786,6 +852,18 @@ function addCodexLifecycleHooks(args, mode, cliPath) {
786
852
  }
787
853
  return [...args.slice(0, -2), ...managed, ...args.slice(-2)];
788
854
  }
855
+ function addCodexManagedLifecycleHooks(args, cliPath) {
856
+ if (args.length < 2 || args.at(-2) !== "app-server" || args.at(-1) !== "--stdio") {
857
+ throw new Error("Managed Codex App Server launch shape is invalid.");
858
+ }
859
+ return [
860
+ ...args.slice(0, -2),
861
+ "--enable", "hooks",
862
+ "--config", codexLifecycleHooksConfig(cliPath),
863
+ "--dangerously-bypass-hook-trust",
864
+ ...args.slice(-2)
865
+ ];
866
+ }
789
867
  function readySession(agentId, adapterId, nativeSessionId, effective) {
790
868
  return {
791
869
  agentId,
@@ -185,9 +185,12 @@ export function foldCanonicalLifecycleEvent(event, expectation) {
185
185
  // Only an identity-matched durable native event can move accepted/delivered,
186
186
  // and only after the independently committed transport receipt. Provider
187
187
  // acceptance and transport acknowledgement are deliberately separate
188
- // evidence layers: neither may repair or infer the other.
188
+ // evidence layers: neither may repair or infer the other. A fresh managed
189
+ // Host can publish native acceptance immediately before its launch call
190
+ // returns and lets the scheduler persist the transport receipt. Retain
191
+ // that exact fenced fact for replay instead of misclassifying it as stale.
189
192
  if (!expectation.pushed)
190
- return { outcome: "fail-closed", reason: "accept-without-push" };
193
+ return { outcome: "deferred", reason: "accept-before-push" };
191
194
  if (expectation.terminal)
192
195
  return { outcome: "obsolete", reason: "accept-after-terminal" };
193
196
  if (expectation.accepted)
@@ -11,7 +11,7 @@
11
11
  * sources) applies identically.
12
12
  */
13
13
  import { applyResourceGc, planResourceGc } from "./resourceGc.js";
14
- import { resolveResourcesGcAutoQuarantine, resolveResourcesGcMode } from "../config/yuiConfig.js";
14
+ import { resolveResourcesGcAutoQuarantine, resolveResourcesGcMode, resolveResourcesQuarantineTtlHours } from "../config/yuiConfig.js";
15
15
  /**
16
16
  * Create the Controller's automatic Resource GC hook. The hook self-skips
17
17
  * unless `resourcesGcMode=quarantine` and `resourcesGcAutoQuarantine=true`.
@@ -35,6 +35,7 @@ export function createResourceAutoGc(options) {
35
35
  taskStatusById,
36
36
  mode: "quarantine",
37
37
  now,
38
+ quarantineTtlHours: resolveResourcesQuarantineTtlHours(config.resourcesQuarantineTtlHours),
38
39
  environment,
39
40
  activeWorkspaceOwnerPaths: collectActiveWorkspaceOwnerPaths(store)
40
41
  };
@@ -75,6 +76,7 @@ export async function runAutoResourceGc(store, options = {}) {
75
76
  taskStatusById,
76
77
  mode: "quarantine",
77
78
  now,
79
+ quarantineTtlHours: resolveResourcesQuarantineTtlHours(config.resourcesQuarantineTtlHours),
78
80
  activeWorkspaceOwnerPaths: collectActiveWorkspaceOwnerPaths(store)
79
81
  };
80
82
  const plan = await planResourceGc(input);
@@ -11,8 +11,6 @@ export const REVIEW_DELTA_RECHECK_MODES = ["enabled", "disabled"];
11
11
  /** Issue 07: conservative defaults for whether a delta attempt is allowed. */
12
12
  export const DEFAULT_DELTA_RECHECK_MAX_CHANGED_LINES = 200;
13
13
  export const DEFAULT_DELTA_RECHECK_MAX_CHANGED_FILES = 5;
14
- /** The Reviewer Role seeded in a new Home by `yui setup`. */
15
- export const DEFAULT_REVIEWER_ROLE = "reviewer";
16
14
  export function validateReviewConfig(config) {
17
15
  requireIdentity(config.roleName, "Review Role");
18
16
  if (!REVIEW_TRIGGERS.includes(config.trigger)) {
@@ -329,7 +329,7 @@ export function withProviderRetry(run, retry) {
329
329
  * remain historical facts; the new delivery receipt and `dispatching` retry
330
330
  * state make the delivery path send only the short continuation envelope.
331
331
  */
332
- export function reopenRunForProviderRetry(run, receiptId, now) {
332
+ export function reopenRunForProviderRetry(run, receiptId, now, mode = "resume") {
333
333
  if (run.status !== "active" || run.providerRetry === undefined) {
334
334
  throw new Error(`Agent run is not waiting for a provider retry: ${run.id}.`);
335
335
  }
@@ -337,7 +337,7 @@ export function reopenRunForProviderRetry(run, receiptId, now) {
337
337
  const { providerRetry, ...rest } = run;
338
338
  return validateAgentRun({
339
339
  ...rest,
340
- mode: "resume",
340
+ mode,
341
341
  deliveryReceiptId: receiptId,
342
342
  updatedAt: timestamp,
343
343
  providerRetry: prepareProviderRetryDispatch(providerRetry, receiptId, now)
@@ -1,24 +1,22 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { DEFAULT_PROVIDER_RETRY_DELAYS_SECONDS, DEFAULT_PROVIDER_RETRY_MAX_WINDOW_SECONDS, MAX_PROVIDER_RETRY_ATTEMPTS } from "../config/yuiConfig.js";
2
3
  import { requireIdentity, requireText, requireTimestamp } from "../domain/validation.js";
3
- export const PROVIDER_RETRY_DELAYS_MS = Object.freeze([2_000, 5_000, 15_000]);
4
- export const PROVIDER_RETRY_MAX_DISPATCHES = PROVIDER_RETRY_DELAYS_MS.length;
5
- export const PROVIDER_RETRY_EPISODE_WINDOW_MS = 600_000;
6
- /** Durable configuration keeps the historical name for compatibility. */
7
- export const PROVIDER_RETRY_MAX_WINDOW_MS = PROVIDER_RETRY_EPISODE_WINDOW_MS;
8
- export function nextProviderRetryDelayMs(retryIndex) {
4
+ export const PROVIDER_RETRY_DELAYS_MS = Object.freeze(DEFAULT_PROVIDER_RETRY_DELAYS_SECONDS.map((seconds) => seconds * 1_000));
5
+ export const PROVIDER_RETRY_EPISODE_WINDOW_MS = DEFAULT_PROVIDER_RETRY_MAX_WINDOW_SECONDS * 1_000;
6
+ export function nextProviderRetryDelayMs(retryIndex, delaysMs = PROVIDER_RETRY_DELAYS_MS) {
9
7
  if (!Number.isSafeInteger(retryIndex)
10
8
  || retryIndex < 1
11
- || retryIndex > PROVIDER_RETRY_MAX_DISPATCHES) {
9
+ || retryIndex > delaysMs.length) {
12
10
  throw new Error(`Provider retry index is out of range: ${String(retryIndex)}.`);
13
11
  }
14
- return PROVIDER_RETRY_DELAYS_MS[retryIndex - 1];
12
+ return delaysMs[retryIndex - 1];
15
13
  }
16
14
  /**
17
15
  * True when the retry lineage has used its total wall-clock budget. The
18
16
  * budget is measured from the first classified failure, so repeated failures
19
17
  * never extend it.
20
18
  */
21
- export function providerRetryBudgetExhausted(value, now, maxWindowMs = PROVIDER_RETRY_MAX_WINDOW_MS) {
19
+ export function providerRetryBudgetExhausted(value, now, maxWindowMs = PROVIDER_RETRY_EPISODE_WINDOW_MS) {
22
20
  if (!Number.isSafeInteger(maxWindowMs) || maxWindowMs <= 0) {
23
21
  throw new Error(`Provider retry max window must be a positive integer: ${String(maxWindowMs)}.`);
24
22
  }
@@ -43,7 +41,9 @@ export function validateAgentRunProviderRetry(value) {
43
41
  || value.dispatchedRetries > value.maxRetries) {
44
42
  throw new Error("Agent run providerRetry dispatchedRetries is invalid.");
45
43
  }
46
- if (value.maxRetries !== PROVIDER_RETRY_MAX_DISPATCHES) {
44
+ if (!Number.isSafeInteger(value.maxRetries)
45
+ || value.maxRetries < 1
46
+ || value.maxRetries > MAX_PROVIDER_RETRY_ATTEMPTS) {
47
47
  throw new Error("Agent run providerRetry maxRetries is invalid.");
48
48
  }
49
49
  requireTimestamp(value.firstFailureAt, "Agent run providerRetry firstFailureAt");
@@ -83,18 +83,22 @@ export function validateAgentRunProviderRetry(value) {
83
83
  return value;
84
84
  }
85
85
  /** Advance one failure episode without ever changing the native Session. */
86
- export function scheduleProviderRetry(previous, input, now) {
86
+ export function scheduleProviderRetry(previous, input, now, policy = {
87
+ delaysMs: PROVIDER_RETRY_DELAYS_MS,
88
+ maxWindowMs: PROVIDER_RETRY_EPISODE_WINDOW_MS
89
+ }) {
90
+ validateRetrySchedulePolicy(policy);
87
91
  const at = now.toISOString();
88
92
  const firstFailureAt = previous?.firstFailureAt ?? at;
89
93
  const episodeDeadlineAt = previous?.episodeDeadlineAt
90
- ?? new Date(now.getTime() + PROVIDER_RETRY_EPISODE_WINDOW_MS).toISOString();
94
+ ?? new Date(now.getTime() + policy.maxWindowMs).toISOString();
91
95
  if (now.getTime() >= Date.parse(episodeDeadlineAt)) {
92
96
  return Object.freeze({ outcome: "exhausted", reason: "window" });
93
97
  }
94
98
  const consecutiveFailures = (previous?.consecutiveFailures ?? 0) + 1;
95
99
  const dispatchedRetries = previous?.dispatchedRetries ?? 0;
96
100
  const schedule = input.scheduleNextAttempt ?? true;
97
- if (schedule && dispatchedRetries >= PROVIDER_RETRY_MAX_DISPATCHES) {
101
+ if (schedule && dispatchedRetries >= policy.delaysMs.length) {
98
102
  return Object.freeze({ outcome: "exhausted", reason: "attempts" });
99
103
  }
100
104
  const state = schedule ? "scheduled" : "blocked";
@@ -103,7 +107,7 @@ export function scheduleProviderRetry(previous, input, now) {
103
107
  throw new Error("Provider retry Retry-After must be a positive safe integer.");
104
108
  }
105
109
  const delayMs = schedule
106
- ? Math.max(nextProviderRetryDelayMs(dispatchedRetries + 1), retryAfterMs ?? 0)
110
+ ? Math.max(nextProviderRetryDelayMs(dispatchedRetries + 1, policy.delaysMs), retryAfterMs ?? 0)
107
111
  : undefined;
108
112
  const nextAttemptAt = delayMs === undefined
109
113
  ? undefined
@@ -125,7 +129,7 @@ export function scheduleProviderRetry(previous, input, now) {
125
129
  errorClass: input.errorClass,
126
130
  consecutiveFailures,
127
131
  dispatchedRetries,
128
- maxRetries: PROVIDER_RETRY_MAX_DISPATCHES,
132
+ maxRetries: policy.delaysMs.length,
129
133
  firstFailureAt,
130
134
  lastFailureAt: at,
131
135
  episodeDeadlineAt,
@@ -139,6 +143,15 @@ export function scheduleProviderRetry(previous, input, now) {
139
143
  });
140
144
  return Object.freeze({ outcome: schedule ? "scheduled" : "blocked", retry });
141
145
  }
146
+ function validateRetrySchedulePolicy(policy) {
147
+ if (!Number.isSafeInteger(policy.maxWindowMs) || policy.maxWindowMs < 1) {
148
+ throw new Error("Provider retry max window must be a positive safe integer.");
149
+ }
150
+ if (policy.delaysMs.length < 1 || policy.delaysMs.length > MAX_PROVIDER_RETRY_ATTEMPTS
151
+ || policy.delaysMs.some((delay) => !Number.isSafeInteger(delay) || delay < 1)) {
152
+ throw new Error(`Provider retry delay schedule must contain 1-${MAX_PROVIDER_RETRY_ATTEMPTS} positive safe integers.`);
153
+ }
154
+ }
142
155
  /** Mark that one short continuation request was dispatched and now awaits any correlated progress. */
143
156
  export function prepareProviderRetryDispatch(value, receiptId, now) {
144
157
  if (value.state !== "scheduled" || value.nextAttemptAt === undefined)
@@ -203,7 +216,7 @@ export function serializeProviderRetryEnvelope(input) {
203
216
  return [
204
217
  "Yui managed in-Session continuation retry.",
205
218
  `task=${requireIdentity(input.taskId, "Provider retry task id")} run=${requireIdentity(input.runId, "Provider retry run id")} role=${requireIdentity(input.roleName, "Provider retry role")}`,
206
- `episode=${input.retry.episodeId} retry=${retryOrdinal}/${PROVIDER_RETRY_MAX_DISPATCHES} receipt=${input.retry.lastRetryReceiptId ?? "pending"}`,
219
+ `episode=${input.retry.episodeId} retry=${retryOrdinal}/${input.retry.maxRetries} receipt=${input.retry.lastRetryReceiptId ?? "pending"}`,
207
220
  `failureEvent=${input.retry.failureEventId}`,
208
221
  ...(input.retry.failedNativeTurnId === undefined
209
222
  ? []
@@ -1,4 +1,4 @@
1
- import { resolveProviderRetryAdapters, resolveProviderRetryMaxWindowMs, resolveProviderRetryMode, resolveYieldReceiptReplay } from "../config/yuiConfig.js";
1
+ import { resolveProviderRetryAdapters, resolveProviderRetryDelaysSeconds, resolveProviderRetryMaxWindowSeconds, resolveProviderRetryMode } from "../config/yuiConfig.js";
2
2
  /**
3
3
  * Resolves the retry flags from the durable Yui config. Homes without the
4
4
  * fields get the safe defaults: enforce mode, all supported adapters, receipt
@@ -10,8 +10,10 @@ export function providerRetryConfig(config) {
10
10
  return {
11
11
  mode: adapters.length === 0 ? "off" : mode,
12
12
  adapters,
13
- yieldReceiptReplay: resolveYieldReceiptReplay(config.yieldReceiptReplay),
14
- maxWindowMs: resolveProviderRetryMaxWindowMs(config.providerRetryMaxWindowMs)
13
+ delaysMs: resolveProviderRetryDelaysSeconds(config.providerRetryDelaysSeconds)
14
+ .map((seconds) => seconds * 1_000),
15
+ maxWindowMs: resolveProviderRetryMaxWindowSeconds(config.providerRetryMaxWindowSeconds)
16
+ * 1_000
15
17
  };
16
18
  }
17
19
  /** Whether the adapter has in-place retry enabled in the given mode. */