@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
@@ -12,7 +12,7 @@ const MAX_RECONCILIATION_PASSES = 4;
12
12
  * and inode fingerprints. Agent, tmux, app, and foreign-Home resources are
13
13
  * deliberately outside this operation.
14
14
  */
15
- export async function reconcileControllerResourcesForUpdate(home, environment = process.env) {
15
+ export async function reconcileControllerResourcesForUpdate(home, environment = process.env, tmuxBin) {
16
16
  const resolvedHome = resolve(home);
17
17
  const releaseLock = await acquireHomeLifecycleLock(resolvedHome, {
18
18
  removeStaleOwner: true
@@ -23,7 +23,8 @@ export async function reconcileControllerResourcesForUpdate(home, environment =
23
23
  const snapshot = await scanControllerResourceInventory({
24
24
  currentHome: resolvedHome,
25
25
  scope: "current",
26
- environment
26
+ environment,
27
+ ...(tmuxBin === undefined ? {} : { tmuxBin })
27
28
  });
28
29
  assertCertainSnapshot(snapshot, resolvedHome);
29
30
  const resources = controllerResources(snapshot, resolvedHome);
@@ -53,7 +54,7 @@ export async function reconcileControllerResourcesForUpdate(home, environment =
53
54
  }
54
55
  for (const candidate of candidates) {
55
56
  try {
56
- await cleanControllerResource(candidate, { environment });
57
+ await cleanControllerResource(candidate, { environment, ...(tmuxBin === undefined ? {} : { tmuxBin }) });
57
58
  cleaned.add(candidate.id);
58
59
  }
59
60
  catch (error) {
@@ -5,6 +5,7 @@ import { configuredAgentToDefinition, resolveAgentEnvironment } from "../agent/a
5
5
  import { operationalAgentEnvironment } from "../agent/launchEnvironment.js";
6
6
  import { inspectAgentCapabilities, resolveAgentAdapter } from "../executor/agentAdapter.js";
7
7
  import { inspectCodexLaunchConfig } from "../executor/codexConfigConflict.js";
8
+ import { nativeAdditionalDirectories, nativeAgentWorkspace, withNativeProjectDirectories } from "../executor/fileRoleLaunchPlanner.js";
8
9
  import { resolveEffectiveLaunch } from "../executor/effectiveLaunch.js";
9
10
  import { compileRoleSessionContext } from "../context/roleSessionContext.js";
10
11
  import { usageError } from "../errors/cliError.js";
@@ -15,6 +16,7 @@ import { inspectStorageSchema } from "../storage/storageSchema.js";
15
16
  import { resolveTaskStoreBackendForHome } from "../storage/sqliteStore.js";
16
17
  import { resolveStoreWorkerEnabledForHome } from "../storage/storeRpc.js";
17
18
  import { classifyHome } from "../storage/upgrade/homeClassification.js";
19
+ import { resolveGitBin, resolveTmuxBin } from "../config/yuiConfig.js";
18
20
  import { readMigrationReceipt } from "../storage/upgrade/migrationReceipt.js";
19
21
  import { latestStorageVersionState } from "../storage/upgrade/recordVersions.js";
20
22
  import { COMMITTED_DATABASE_FILENAME } from "../storage/upgrade/sqliteStateMigration.js";
@@ -127,9 +129,10 @@ function inspectDoctor(env, executor, storageOptions = {}) {
127
129
  const schemaCheck = checkSchema(schema, compatibility);
128
130
  const storage = inspectState(home, homeCheck, schema, compatibility);
129
131
  const domain = checkEphemeralDomain(home);
132
+ const durableConfig = readDurableConfigSafely(home, compatibility.storageOptions);
130
133
  const toolChecks = [
131
- checkExecutable("git", env.YUI_GIT_BIN ?? "git", ["--version"], executor),
132
- checkExecutable("tmux", env.YUI_TMUX_BIN ?? "tmux", ["-V"], executor)
134
+ checkExecutable("git", durableConfig.gitBin, ["--version"], executor),
135
+ checkExecutable("tmux", durableConfig.tmuxBin, ["-V"], executor)
133
136
  ];
134
137
  const agentChecks = storage.agents.flatMap((agent) => checkAgent(agent, executor, env));
135
138
  const review = inspectReview({ ...storage.review, home }, agentChecks, env);
@@ -776,9 +779,8 @@ function checkReviewerLaunch(agent, role, binding, adapter, environment, home) {
776
779
  const definition = configuredAgentToDefinition(agent);
777
780
  const launchEnvironment = resolveDoctorAgentEnvironment(definition, environment);
778
781
  const effective = resolveEffectiveLaunch({ role, purpose: "execution" });
779
- const agentWorkspace = effective.workspace.entries.length === 1
780
- ? effective.workspace.entries[0].path
781
- : effective.workspace.root;
782
+ const agentWorkspace = nativeAgentWorkspace(effective.workspace);
783
+ const launchConfig = withNativeProjectDirectories(binding.config, nativeAdditionalDirectories(effective.workspace, agentWorkspace));
782
784
  const codexConfig = adapter.id === "codex"
783
785
  ? inspectCodexLaunchConfig({
784
786
  environment: launchEnvironment,
@@ -801,7 +803,7 @@ function checkReviewerLaunch(agent, role, binding, adapter, environment, home) {
801
803
  : undefined;
802
804
  const compiled = adapter.compileNew({
803
805
  agent: definition,
804
- config: binding.config,
806
+ config: launchConfig,
805
807
  workspace: agentWorkspace,
806
808
  sessionTitle: "reviewer",
807
809
  ...(reviewerContext === undefined
@@ -817,7 +819,7 @@ function checkReviewerLaunch(agent, role, binding, adapter, environment, home) {
817
819
  return {
818
820
  name: "reviewer launch",
819
821
  status: "ok",
820
- detail: `adapter=${adapter.id} strategy=${compiled.sessionStrategy} command=${agent.command}`
822
+ detail: `adapter=${adapter.id} strategy=${compiled.sessionStrategy} command=${agent.command} addDirs=${launchConfig.additionalDirectories?.length ?? 0}`
821
823
  };
822
824
  }
823
825
  catch (error) {
@@ -973,3 +975,20 @@ function systemCode(error) {
973
975
  function errorMessage(error) {
974
976
  return error instanceof Error ? error.message : String(error);
975
977
  }
978
+ /**
979
+ * Read the durable config for executable paths. Falls back to defaults when
980
+ * the Home store cannot be opened (the doctor must still run on broken Homes).
981
+ */
982
+ function readDurableConfigSafely(home, storageOptions) {
983
+ try {
984
+ const store = openCompatibleFileTaskStore(home, storageOptions ?? {});
985
+ const config = store.getConfig();
986
+ return {
987
+ tmuxBin: resolveTmuxBin(config.tmuxBin),
988
+ gitBin: resolveGitBin(config.gitBin)
989
+ };
990
+ }
991
+ catch {
992
+ return { tmuxBin: "tmux", gitBin: "git" };
993
+ }
994
+ }
@@ -156,6 +156,24 @@ export function modelChoice(catalog, value) {
156
156
  ? defaultModel(catalog)
157
157
  : catalog.models.find((model) => model.value === value);
158
158
  }
159
+ /**
160
+ * Validates the launch-time model/effort against a live or cached capability
161
+ * catalog. A fallback catalog intentionally carries no model list, so an
162
+ * unavailable Provider probe does not block a supported launch.
163
+ */
164
+ export function validateAgentLaunchConfiguration(catalog, config) {
165
+ if (catalog.models.length === 0)
166
+ return;
167
+ const model = modelChoice(catalog, config.model);
168
+ if (model === undefined) {
169
+ throw new Error(`Unsupported ${catalog.adapterId} launch configuration: field=model actual=${JSON.stringify(config.model ?? "")} supported=${JSON.stringify(catalog.models.map(({ value }) => value))}.`);
170
+ }
171
+ if (config.effort !== undefined
172
+ && model.efforts.length > 0
173
+ && !model.efforts.some((effort) => effort.value === config.effort)) {
174
+ throw new Error(`Unsupported ${catalog.adapterId} launch configuration: field=effort actual=${JSON.stringify(config.effort)} model=${JSON.stringify(model.value)} supported=${JSON.stringify(model.efforts.map(({ value }) => value))}.`);
175
+ }
176
+ }
159
177
  function field(key, choices, allowCustom) {
160
178
  return { key, choices, allowCustom };
161
179
  }
@@ -547,16 +547,16 @@ export class FileRoleLaunchPlanner {
547
547
  return selectEnvironment(source, names);
548
548
  }
549
549
  }
550
- function nativeAgentWorkspace(workspace) {
550
+ export function nativeAgentWorkspace(workspace) {
551
551
  return workspace.entries.length === 1
552
552
  ? workspace.entries[0].path
553
553
  : workspace.root;
554
554
  }
555
- function nativeAdditionalDirectories(workspace, agentWorkspace) {
555
+ export function nativeAdditionalDirectories(workspace, agentWorkspace) {
556
556
  return [workspace.root, ...workspace.entries.map(({ path }) => path)]
557
557
  .filter((path) => path !== agentWorkspace);
558
558
  }
559
- function withNativeProjectDirectories(config, projectDirectories) {
559
+ export function withNativeProjectDirectories(config, projectDirectories) {
560
560
  if (projectDirectories.length === 0)
561
561
  return config;
562
562
  return {
@@ -0,0 +1,81 @@
1
+ import { enqueueWork } from "../coordination/workMailboxQueue.js";
2
+ import { validateRoleSessionSet } from "../executor/agentExecutor.js";
3
+ import { createTaskEvent } from "../event/taskEvent.js";
4
+ import { RUNTIME_CLEANUP_REQUIRED_REASON, runtimeLifecycleTarget } from "../runtime/lifecycleReservation.js";
5
+ /**
6
+ * Issue 04 (context token budget): retires one native Session generation that
7
+ * crossed the hard context budget so the next Leader wake starts a fresh
8
+ * generation. This is a controlled rollover, not a failure: the durable Task
9
+ * records are the checkpoint, the bounded context snapshot re-establishes
10
+ * working context for the new generation, and the retired Session is kept in
11
+ * history as `stopped` with an auditable event. The Controller separately
12
+ * owns verified process cleanup through the runtime cleanup lane.
13
+ */
14
+ export const CONTEXT_BUDGET_ROLLOVER_REASON = "context-budget-hard-limit";
15
+ export function rolloverTaskRoleSessionForContextBudget(store, taskId, roleName, evidence, now) {
16
+ const task = store.getTask(taskId);
17
+ if (task === null)
18
+ throw new Error(`Task not found: ${taskId}.`);
19
+ if (task.status !== "active") {
20
+ throw new Error(`Task is not active: ${task.id}/${task.status}.`);
21
+ }
22
+ const role = store.getRole(task.id, roleName);
23
+ if (role === null)
24
+ throw new Error(`Role not found: ${task.id}/${roleName}.`);
25
+ const set = store.getTaskRoleSessionSet(task.id, role.name);
26
+ const current = set?.sessions[set.activeAgentId];
27
+ if (set === null || set === undefined || current === undefined) {
28
+ // No live generation to retire; the caller can launch fresh directly.
29
+ return null;
30
+ }
31
+ const timestamp = now.toISOString();
32
+ const reason = `${CONTEXT_BUDGET_ROLLOVER_REASON} (peak ${evidence.peakTokens} >= hard ${evidence.hardTokens} tokens)`;
33
+ enqueueWork(store, runtimeLifecycleTarget({ scope: "task", taskId: task.id, roleName: role.name }), RUNTIME_CLEANUP_REQUIRED_REASON, now, [{ type: "task", id: task.id }]);
34
+ const retired = retireStoppedTaskRoleSession(set, timestamp);
35
+ store.saveTaskRoleSessionSet(retired);
36
+ const eventId = store.nextEventId(task.id);
37
+ store.saveEvent(task.id, createTaskEvent(eventId, task.id, "runtime.role-session-reset", {
38
+ roleName: role.name,
39
+ reason,
40
+ peakTokens: String(evidence.peakTokens),
41
+ hardTokens: String(evidence.hardTokens),
42
+ ...(current.nativeSessionId === undefined
43
+ ? {}
44
+ : { nativeSessionId: current.nativeSessionId }),
45
+ ...(current.launchId === undefined ? {} : { launchId: current.launchId })
46
+ }, now));
47
+ return Object.freeze({
48
+ taskId: task.id,
49
+ roleName: role.name,
50
+ eventId,
51
+ ...(current.nativeSessionId === undefined
52
+ ? {}
53
+ : { retiredNativeSessionId: current.nativeSessionId }),
54
+ ...(current.launchId === undefined ? {} : { retiredLaunchId: current.launchId })
55
+ });
56
+ }
57
+ /**
58
+ * Retires the current generation to history as `stopped` (not `broken`:
59
+ * a budget rollover is not a Session failure) and clears the Turn fence so
60
+ * the next launch is a fresh generation.
61
+ */
62
+ function retireStoppedTaskRoleSession(set, timestamp) {
63
+ validateRoleSessionSet(set);
64
+ const current = set.sessions[set.activeAgentId];
65
+ const sessions = { ...set.sessions };
66
+ delete sessions[set.activeAgentId];
67
+ const history = current === undefined
68
+ ? set.history
69
+ : [
70
+ ...(set.history ?? []),
71
+ { ...current, status: "stopped", updatedAt: timestamp }
72
+ ];
73
+ return validateRoleSessionSet({
74
+ ...set,
75
+ sessions,
76
+ ...(history === undefined ? {} : { history }),
77
+ inFlight: null,
78
+ providerBinding: null,
79
+ updatedAt: timestamp
80
+ });
81
+ }
@@ -13,6 +13,7 @@ import { recordExecutionLaneResult } from "../execution/executionGroup.js";
13
13
  import { formatAgentRunReceiptId } from "../task/taskRecordReference.js";
14
14
  import { isRuntimeLaunchReservation, runtimeLifecycleTarget } from "../runtime/lifecycleReservation.js";
15
15
  import { clearMatchingLeaderStallAttention, latestRunDurableProgressAt, RUN_RECOVERY_APPLIED_EVENT, RUN_RECOVERY_REQUESTED_EVENT } from "../scheduler/roleRunStall.js";
16
+ import { markTaskWakeConsumed } from "../scheduler/taskWake.js";
16
17
  import { workItemExecutionGroupById, updateWorkItemExecutionGroup, updateWorkItemStatus } from "../workItem/workItem.js";
17
18
  /**
18
19
  * Validate every immutable identity and frozen Project head needed before a
@@ -283,7 +284,9 @@ export function terminalizeExactTaskRun(store, input, now) {
283
284
  ...(input.reviewResult.findings === undefined ? {} : { findings: input.reviewResult.findings }),
284
285
  ...(input.reviewResult.evidence === undefined ? {} : { evidence: input.reviewResult.evidence }),
285
286
  ...(input.reviewResult.evidenceCommit === undefined ? {} : { evidenceCommit: input.reviewResult.evidenceCommit }),
286
- ...(input.reviewResult.gitSnapshot === undefined ? {} : { gitSnapshot: input.reviewResult.gitSnapshot })
287
+ ...(input.reviewResult.gitSnapshot === undefined ? {} : { gitSnapshot: input.reviewResult.gitSnapshot }),
288
+ ...(input.reviewResult.deltaDisposition === undefined ? {} : { deltaDisposition: input.reviewResult.deltaDisposition }),
289
+ ...(input.reviewResult.deltaReasoning === undefined ? {} : { deltaReasoning: input.reviewResult.deltaReasoning })
287
290
  }
288
291
  })
289
292
  }, now))
@@ -310,6 +313,13 @@ export function terminalizeExactTaskRun(store, input, now) {
310
313
  }
311
314
  }
312
315
  store.saveAgentRun(terminal);
316
+ if (terminal.roleName === "leader") {
317
+ const wake = store.listTaskWakes(input.taskId)
318
+ .find((candidate) => candidate.runId === terminal.id && candidate.status === "dispatched");
319
+ if (wake !== undefined) {
320
+ store.saveTaskWake(input.taskId, markTaskWakeConsumed(wake, now));
321
+ }
322
+ }
313
323
  if (terminal.executionGroupId !== undefined && terminal.executionLaneId !== undefined) {
314
324
  store.clearActiveExecutionLaneRun(input.taskId, terminal.executionGroupId, terminal.executionLaneId);
315
325
  }
@@ -1,11 +1,15 @@
1
1
  /**
2
2
  * Issue 04 — Provider error classification.
3
3
  *
4
- * Provider failures arrive as opaque free text (Claude StopFailure
5
- * `error`/`errorDetails`, Codex turn-completion summaries). The retry-in-place
6
- * coordinator needs a stable, provider-neutral error class before it can decide
7
- * whether the original Session may be retried. This module is the single
8
- * classifier: it is pure, table-driven, and deliberately conservative.
4
+ * Provider failures arrive at the driver boundary as opaque free text (Claude
5
+ * StopFailure `error`/`errorDetails`, Codex turn-completion summaries). Each
6
+ * driver parses its own Provider's format into a structured
7
+ * {@link ProviderErrorCode} at the driver boundary. This module maps those
8
+ * codes to provider-neutral error classes by lookup, falling back to text
9
+ * matching only when the driver could not produce a structured code.
10
+ *
11
+ * The retry-in-place coordinator needs a stable, provider-neutral error class
12
+ * before it can decide whether the original Session may be retried.
9
13
  *
10
14
  * Classes (Issue 04 §2):
11
15
  * - `transient-provider` — 500/502/504, connection reset, backend capacity;
@@ -25,6 +29,7 @@
25
29
  * terminalize-immediately behavior) while remaining
26
30
  * observable in shadow metrics.
27
31
  */
32
+ import { PROVIDER_ERROR_CODE_CLASS } from "../runtime/providerErrorCodes.js";
28
33
  /** Classes for which the original Session may be retried in place. */
29
34
  export const RETRYABLE_PROVIDER_ERROR_CLASSES = [
30
35
  "transient-provider",
@@ -69,8 +74,11 @@ const INVALID_REQUEST_PATTERNS = [
69
74
  ];
70
75
  const TRANSIENT_PROVIDER_PATTERNS = [
71
76
  { pattern: /\b50[024]\b/u, label: "http-5xx" },
72
- { pattern: /server[_-]?error/iu, label: "server-error" },
77
+ { pattern: /server[\s_-]?error/iu, label: "server-error" },
73
78
  { pattern: /internal server error/iu, label: "internal-server-error" },
79
+ // HTTP/2 RST_STREAM / gRPC status carried by Claude Code and Codex streams
80
+ // (Task-27: "stream error: stream ID …; INTERNAL_ERROR; received from peer").
81
+ { pattern: /\binternal[\s_-]?error\b/iu, label: "internal-error" },
74
82
  { pattern: /connection lost/iu, label: "connection-lost" },
75
83
  { pattern: /connection reset/iu, label: "connection-reset" },
76
84
  { pattern: /econnreset/iu, label: "econnreset" },
@@ -91,6 +99,9 @@ const TRANSPORT_UNCERTAIN_PATTERNS = [
91
99
  { pattern: /etimedout/iu, label: "etimedout" },
92
100
  { pattern: /response lost/iu, label: "response-lost" },
93
101
  { pattern: /lost response/iu, label: "lost-response" },
102
+ // A stream-level failure means the response may have been cut mid-turn;
103
+ // the retry path consults durable completion facts before any resend.
104
+ { pattern: /stream error/iu, label: "stream-error" },
94
105
  { pattern: /stream interrupted/iu, label: "stream-interrupted" },
95
106
  { pattern: /interrupted function/iu, label: "interrupted-function" },
96
107
  { pattern: /controller timeout/iu, label: "controller-timeout" },
@@ -106,21 +117,31 @@ const CLASS_TABLE = [
106
117
  { errorClass: "transport-uncertain", patterns: TRANSPORT_UNCERTAIN_PATTERNS }
107
118
  ];
108
119
  /**
109
- * Classifies one provider failure. Every available text field is concatenated
110
- * so a class can be recognized regardless of which field carried it. Unknown
111
- * text is `unclassified`, which keeps the old fail-immediately behavior.
120
+ * Classifies one provider failure. When the driver produced a structured
121
+ * {@link ProviderErrorCode}, the class is looked up directly. Otherwise the
122
+ * raw text fields are matched against the fallback pattern tables. Every
123
+ * available text field is concatenated so a class can be recognized
124
+ * regardless of which field carried it.
112
125
  */
113
126
  export function classifyProviderError(input) {
127
+ // Structured path: the driver already parsed the Provider's error format.
128
+ if (input.errorCode !== undefined) {
129
+ const errorClass = PROVIDER_ERROR_CODE_CLASS[input.errorCode];
130
+ if (errorClass !== undefined) {
131
+ return { errorClass, matched: input.errorCode, basis: "structured" };
132
+ }
133
+ }
134
+ // Text fallback: for drivers that cannot yet produce a structured code.
114
135
  const text = [input.error, input.errorDetails, input.summary]
115
136
  .filter((value) => typeof value === "string" && value.length > 0)
116
137
  .join("\n");
117
138
  if (text.length === 0)
118
- return { errorClass: "unclassified", matched: "none" };
139
+ return { errorClass: "unclassified", matched: "none", basis: "text" };
119
140
  for (const { errorClass, patterns } of CLASS_TABLE) {
120
141
  for (const { pattern, label } of patterns) {
121
142
  if (pattern.test(text))
122
- return { errorClass, matched: label };
143
+ return { errorClass, matched: label, basis: "text" };
123
144
  }
124
145
  }
125
- return { errorClass: "unclassified", matched: "none" };
146
+ return { errorClass: "unclassified", matched: "none", basis: "text" };
126
147
  }