cascade-ai 0.49.0 → 0.50.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.
package/dist/index.d.cts CHANGED
@@ -1858,6 +1858,12 @@ declare abstract class BaseTier extends EventEmitter {
1858
1858
  getStatus(): TierStatus;
1859
1859
  protected setStatus(status: TierStatus, output?: string): void;
1860
1860
  /** Record the model serving this tier; future status events carry it. */
1861
+ /**
1862
+ * The model actually serving this tier ("provider:id"), once one has been
1863
+ * chosen. The run breaker reads it to attribute a failure to a model rather
1864
+ * than to the subtask that happened to hit it.
1865
+ */
1866
+ getServingModel(): string | undefined;
1861
1867
  protected setServingModel(model: string | undefined): void;
1862
1868
  protected setLabel(label: string): void;
1863
1869
  setSystemPromptOverride(prompt: string): void;
@@ -1873,6 +1879,61 @@ declare abstract class BaseTier extends EventEmitter {
1873
1879
  protected throwIfCancelled(): void;
1874
1880
  }
1875
1881
 
1882
+ /**
1883
+ * What kind of failure this is, and — crucially — whether retrying a DIFFERENT
1884
+ * subtask against the same model could plausibly succeed.
1885
+ */
1886
+ type ProviderErrorKind = 'rate_limit' | 'quota_exhausted' | 'auth' | 'model_unavailable' | 'context_length' | 'content_filter' | 'network' | 'unknown';
1887
+ interface ClassifiedError {
1888
+ kind: ProviderErrorKind;
1889
+ /** True when every other worker on this model is expected to fail the same way. */
1890
+ systemic: boolean;
1891
+ /** HTTP status, when the provider gave us one. */
1892
+ status?: number;
1893
+ /** The provider's own message, trimmed — never invented or paraphrased. */
1894
+ raw: string;
1895
+ }
1896
+
1897
+ interface BreakerTrip {
1898
+ modelId: string;
1899
+ kind: ClassifiedError['kind'];
1900
+ /** How many systemic failures on this model were seen before tripping. */
1901
+ failures: number;
1902
+ /** User-facing explanation, including the provider's own words. */
1903
+ message: string;
1904
+ }
1905
+ declare class RunBreaker {
1906
+ private readonly threshold;
1907
+ /** modelId → consecutive systemic failures of the SAME kind. */
1908
+ private counts;
1909
+ private trip;
1910
+ constructor(threshold?: number);
1911
+ /**
1912
+ * Record a failed model call. Returns the trip if this failure is the one
1913
+ * that opened the breaker, otherwise null.
1914
+ *
1915
+ * A non-systemic failure RESETS the model's counter: it is evidence the model
1916
+ * is reachable and answering, which is exactly what a systemic streak claims
1917
+ * it is not.
1918
+ */
1919
+ record(err: unknown, modelId: string | undefined): BreakerTrip | null;
1920
+ /** True once the run should stop launching work that would fail identically. */
1921
+ isOpen(): boolean;
1922
+ /** Why the breaker opened, or null while it is closed. */
1923
+ reason(): BreakerTrip | null;
1924
+ /**
1925
+ * One line for a worker/section that never ran because the breaker was
1926
+ * already open. Says the work was skipped deliberately — not that it failed
1927
+ * on its own merits, which is what an empty FAILED status would imply.
1928
+ */
1929
+ skipMessage(): string;
1930
+ /** Test/diagnostic view of the per-model streaks. */
1931
+ streaks(): Record<string, {
1932
+ kind: string;
1933
+ n: number;
1934
+ }>;
1935
+ }
1936
+
1876
1937
  type T2Evaluator = (req: PermissionRequest) => Promise<PermissionDecision | null>;
1877
1938
  type T1Evaluator = (req: PermissionRequest) => Promise<PermissionDecision | null>;
1878
1939
  /**
@@ -2020,6 +2081,7 @@ declare class T1Administrator extends BaseTier {
2020
2081
  private t2Managers;
2021
2082
  private escalations;
2022
2083
  private store?;
2084
+ private runBreaker?;
2023
2085
  private t2PeerBus;
2024
2086
  private permissionEscalator?;
2025
2087
  private toolCreator?;
@@ -2029,6 +2091,8 @@ declare class T1Administrator extends BaseTier {
2029
2091
  private peerMessageSessionId;
2030
2092
  private planApprovalCallback?;
2031
2093
  constructor(router: CascadeRouter, toolRegistry: ToolRegistry, config: CascadeConfig);
2094
+ /** Share the run-wide circuit breaker with every section manager. */
2095
+ setRunBreaker(breaker: RunBreaker): void;
2032
2096
  setStore(store: MemoryStore): void;
2033
2097
  /**
2034
2098
  * Inject the shared PermissionEscalator for this task run.
@@ -2404,6 +2468,7 @@ declare class T2Manager extends BaseTier {
2404
2468
  private escalations;
2405
2469
  private peerSyncBuffer;
2406
2470
  private store?;
2471
+ private runBreaker?;
2407
2472
  private t3PeerBus;
2408
2473
  private t2PeerBus?;
2409
2474
  private permissionEscalator?;
@@ -2415,6 +2480,13 @@ declare class T2Manager extends BaseTier {
2415
2480
  setPeerBus(bus: PeerBus): void;
2416
2481
  setPeerMessageCallback(cb: (event: PeerMessageEvent) => void, sessionId: string): void;
2417
2482
  constructor(router: CascadeRouter, toolRegistry: ToolRegistry, parentId: string);
2483
+ /**
2484
+ * Share the run-wide circuit breaker. Every T2 in a run gets the SAME
2485
+ * instance: three sections each failing once against a dead model is the
2486
+ * same fact as one section failing three times, and a per-section breaker
2487
+ * would never see it.
2488
+ */
2489
+ setRunBreaker(breaker: RunBreaker): void;
2418
2490
  setStore(store: MemoryStore): void;
2419
2491
  /**
2420
2492
  * Inject the shared PermissionEscalator for this task run.
package/dist/index.d.ts CHANGED
@@ -1858,6 +1858,12 @@ declare abstract class BaseTier extends EventEmitter {
1858
1858
  getStatus(): TierStatus;
1859
1859
  protected setStatus(status: TierStatus, output?: string): void;
1860
1860
  /** Record the model serving this tier; future status events carry it. */
1861
+ /**
1862
+ * The model actually serving this tier ("provider:id"), once one has been
1863
+ * chosen. The run breaker reads it to attribute a failure to a model rather
1864
+ * than to the subtask that happened to hit it.
1865
+ */
1866
+ getServingModel(): string | undefined;
1861
1867
  protected setServingModel(model: string | undefined): void;
1862
1868
  protected setLabel(label: string): void;
1863
1869
  setSystemPromptOverride(prompt: string): void;
@@ -1873,6 +1879,61 @@ declare abstract class BaseTier extends EventEmitter {
1873
1879
  protected throwIfCancelled(): void;
1874
1880
  }
1875
1881
 
1882
+ /**
1883
+ * What kind of failure this is, and — crucially — whether retrying a DIFFERENT
1884
+ * subtask against the same model could plausibly succeed.
1885
+ */
1886
+ type ProviderErrorKind = 'rate_limit' | 'quota_exhausted' | 'auth' | 'model_unavailable' | 'context_length' | 'content_filter' | 'network' | 'unknown';
1887
+ interface ClassifiedError {
1888
+ kind: ProviderErrorKind;
1889
+ /** True when every other worker on this model is expected to fail the same way. */
1890
+ systemic: boolean;
1891
+ /** HTTP status, when the provider gave us one. */
1892
+ status?: number;
1893
+ /** The provider's own message, trimmed — never invented or paraphrased. */
1894
+ raw: string;
1895
+ }
1896
+
1897
+ interface BreakerTrip {
1898
+ modelId: string;
1899
+ kind: ClassifiedError['kind'];
1900
+ /** How many systemic failures on this model were seen before tripping. */
1901
+ failures: number;
1902
+ /** User-facing explanation, including the provider's own words. */
1903
+ message: string;
1904
+ }
1905
+ declare class RunBreaker {
1906
+ private readonly threshold;
1907
+ /** modelId → consecutive systemic failures of the SAME kind. */
1908
+ private counts;
1909
+ private trip;
1910
+ constructor(threshold?: number);
1911
+ /**
1912
+ * Record a failed model call. Returns the trip if this failure is the one
1913
+ * that opened the breaker, otherwise null.
1914
+ *
1915
+ * A non-systemic failure RESETS the model's counter: it is evidence the model
1916
+ * is reachable and answering, which is exactly what a systemic streak claims
1917
+ * it is not.
1918
+ */
1919
+ record(err: unknown, modelId: string | undefined): BreakerTrip | null;
1920
+ /** True once the run should stop launching work that would fail identically. */
1921
+ isOpen(): boolean;
1922
+ /** Why the breaker opened, or null while it is closed. */
1923
+ reason(): BreakerTrip | null;
1924
+ /**
1925
+ * One line for a worker/section that never ran because the breaker was
1926
+ * already open. Says the work was skipped deliberately — not that it failed
1927
+ * on its own merits, which is what an empty FAILED status would imply.
1928
+ */
1929
+ skipMessage(): string;
1930
+ /** Test/diagnostic view of the per-model streaks. */
1931
+ streaks(): Record<string, {
1932
+ kind: string;
1933
+ n: number;
1934
+ }>;
1935
+ }
1936
+
1876
1937
  type T2Evaluator = (req: PermissionRequest) => Promise<PermissionDecision | null>;
1877
1938
  type T1Evaluator = (req: PermissionRequest) => Promise<PermissionDecision | null>;
1878
1939
  /**
@@ -2020,6 +2081,7 @@ declare class T1Administrator extends BaseTier {
2020
2081
  private t2Managers;
2021
2082
  private escalations;
2022
2083
  private store?;
2084
+ private runBreaker?;
2023
2085
  private t2PeerBus;
2024
2086
  private permissionEscalator?;
2025
2087
  private toolCreator?;
@@ -2029,6 +2091,8 @@ declare class T1Administrator extends BaseTier {
2029
2091
  private peerMessageSessionId;
2030
2092
  private planApprovalCallback?;
2031
2093
  constructor(router: CascadeRouter, toolRegistry: ToolRegistry, config: CascadeConfig);
2094
+ /** Share the run-wide circuit breaker with every section manager. */
2095
+ setRunBreaker(breaker: RunBreaker): void;
2032
2096
  setStore(store: MemoryStore): void;
2033
2097
  /**
2034
2098
  * Inject the shared PermissionEscalator for this task run.
@@ -2404,6 +2468,7 @@ declare class T2Manager extends BaseTier {
2404
2468
  private escalations;
2405
2469
  private peerSyncBuffer;
2406
2470
  private store?;
2471
+ private runBreaker?;
2407
2472
  private t3PeerBus;
2408
2473
  private t2PeerBus?;
2409
2474
  private permissionEscalator?;
@@ -2415,6 +2480,13 @@ declare class T2Manager extends BaseTier {
2415
2480
  setPeerBus(bus: PeerBus): void;
2416
2481
  setPeerMessageCallback(cb: (event: PeerMessageEvent) => void, sessionId: string): void;
2417
2482
  constructor(router: CascadeRouter, toolRegistry: ToolRegistry, parentId: string);
2483
+ /**
2484
+ * Share the run-wide circuit breaker. Every T2 in a run gets the SAME
2485
+ * instance: three sections each failing once against a dead model is the
2486
+ * same fact as one section failing three times, and a per-section breaker
2487
+ * would never see it.
2488
+ */
2489
+ setRunBreaker(breaker: RunBreaker): void;
2418
2490
  setStore(store: MemoryStore): void;
2419
2491
  /**
2420
2492
  * Inject the shared PermissionEscalator for this task run.
package/dist/index.js CHANGED
@@ -1845,7 +1845,7 @@ function reconcilePrice(model, live) {
1845
1845
  }
1846
1846
 
1847
1847
  // src/constants.ts
1848
- var CASCADE_VERSION = "0.49.0";
1848
+ var CASCADE_VERSION = "0.50.0";
1849
1849
  var CASCADE_CONFIG_DIR = ".cascade";
1850
1850
  var CASCADE_MD_FILE = "CASCADE.md";
1851
1851
  var CASCADE_IGNORE_FILE = ".cascadeignore";
@@ -5461,6 +5461,14 @@ var BaseTier = class extends EventEmitter {
5461
5461
  this.emit("tier:status", event);
5462
5462
  }
5463
5463
  /** Record the model serving this tier; future status events carry it. */
5464
+ /**
5465
+ * The model actually serving this tier ("provider:id"), once one has been
5466
+ * chosen. The run breaker reads it to attribute a failure to a model rather
5467
+ * than to the subtask that happened to hit it.
5468
+ */
5469
+ getServingModel() {
5470
+ return this.servingModel;
5471
+ }
5464
5472
  setServingModel(model) {
5465
5473
  this.servingModel = model || void 0;
5466
5474
  }
@@ -7196,6 +7204,7 @@ var T2Manager = class extends BaseTier {
7196
7204
  escalations = [];
7197
7205
  peerSyncBuffer = [];
7198
7206
  store;
7207
+ runBreaker;
7199
7208
  t3PeerBus = new PeerBus();
7200
7209
  // ← T3↔T3 bus (local to this T2)
7201
7210
  t2PeerBus;
@@ -7226,6 +7235,15 @@ var T2Manager = class extends BaseTier {
7226
7235
  this.router = router;
7227
7236
  this.toolRegistry = toolRegistry;
7228
7237
  }
7238
+ /**
7239
+ * Share the run-wide circuit breaker. Every T2 in a run gets the SAME
7240
+ * instance: three sections each failing once against a dead model is the
7241
+ * same fact as one section failing three times, and a per-section breaker
7242
+ * would never see it.
7243
+ */
7244
+ setRunBreaker(breaker) {
7245
+ this.runBreaker = breaker;
7246
+ }
7229
7247
  setStore(store) {
7230
7248
  this.store = store;
7231
7249
  }
@@ -7349,7 +7367,8 @@ Guidance (must be followed): ${decision.note}`
7349
7367
  const overallStatus = this.determineStatus(t3Results);
7350
7368
  const isOk = overallStatus === "COMPLETED" || overallStatus === "PARTIAL";
7351
7369
  this.setStatus(isOk ? "COMPLETED" : "FAILED", summary);
7352
- this.sendStatusUpdate({ progressPct: 100, currentAction: "Section complete", status: "IN_PROGRESS", output: summary });
7370
+ const sectionAction = overallStatus === "COMPLETED" ? "Section complete" : overallStatus === "PARTIAL" ? "Section partially complete \u2014 some subtasks failed" : overallStatus === "ESCALATED" ? "Section escalated \u2014 needs a decision" : "Section failed \u2014 no subtask completed";
7371
+ this.sendStatusUpdate({ progressPct: 100, currentAction: sectionAction, status: "IN_PROGRESS", output: summary });
7353
7372
  const result = {
7354
7373
  sectionId: assignment.sectionId,
7355
7374
  sectionTitle: assignment.sectionTitle,
@@ -7519,6 +7538,24 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
7519
7538
  const reinforceCfg = this.router.getReinforcementsConfig?.() ?? { enabled: false, maxPerSection: 4 };
7520
7539
  let reinforcementsAdded = 0;
7521
7540
  while (remaining.size > 0) {
7541
+ if (this.runBreaker?.isOpen()) {
7542
+ const why = this.runBreaker.skipMessage();
7543
+ this.log(`Run breaker open \u2014 skipping ${remaining.size} remaining subtask(s)`);
7544
+ for (const id of remaining) {
7545
+ this.t3PeerBus.publish(this.id, id, why, "FAILED");
7546
+ resultMap.set(id, {
7547
+ subtaskId: id,
7548
+ status: "FAILED",
7549
+ output: why,
7550
+ testResults: { checksRun: [], passed: [], failed: [] },
7551
+ issues: [why],
7552
+ peerSyncsUsed: [],
7553
+ correctionAttempts: 0
7554
+ });
7555
+ }
7556
+ remaining.clear();
7557
+ break;
7558
+ }
7522
7559
  const runnableIds = [...remaining].filter((id) => (inDegree.get(id) ?? 0) === 0);
7523
7560
  if (runnableIds.length === 0) {
7524
7561
  const fallbackId = [...remaining].sort(
@@ -7621,8 +7658,32 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
7621
7658
  remaining.delete(id);
7622
7659
  const r = waveResults[i];
7623
7660
  if (r.status === "rejected") {
7624
- this.log(`T3 worker ${id} failed: ${r.reason instanceof Error ? r.reason.message : String(r.reason)} \u2014 retrying once`);
7625
7661
  const assignment = sanitizedAssignments.find((a) => a.subtaskId === id);
7662
+ const failedModel = workerMap.get(id)?.getServingModel();
7663
+ const tripped = this.runBreaker?.record(r.reason, failedModel);
7664
+ if (tripped) {
7665
+ this.log(`Run breaker opened: ${tripped.failures} consecutive ${tripped.kind} failures on ${tripped.modelId} \u2014 stopping`);
7666
+ this.emit("run:breaker", tripped);
7667
+ }
7668
+ if (this.runBreaker?.isOpen()) {
7669
+ const why = this.runBreaker.skipMessage();
7670
+ this.log(`T3 worker ${id} failed and the run breaker is open \u2014 not retrying`);
7671
+ this.t3PeerBus.publish(this.id, id, why, "FAILED");
7672
+ resultMap.set(id, {
7673
+ subtaskId: id,
7674
+ status: "FAILED",
7675
+ output: why,
7676
+ testResults: { checksRun: [], passed: [], failed: [] },
7677
+ issues: [why],
7678
+ peerSyncsUsed: [],
7679
+ correctionAttempts: 0
7680
+ });
7681
+ for (const dependent of adj.get(id) ?? []) {
7682
+ inDegree.set(dependent, Math.max(0, (inDegree.get(dependent) ?? 0) - 1));
7683
+ }
7684
+ continue;
7685
+ }
7686
+ this.log(`T3 worker ${id} failed: ${r.reason instanceof Error ? r.reason.message : String(r.reason)} \u2014 retrying once`);
7626
7687
  try {
7627
7688
  const retried = await this.retryT3(assignment, taskId);
7628
7689
  resultMap.set(id, retried);
@@ -7954,6 +8015,7 @@ var T1Administrator = class extends BaseTier {
7954
8015
  t2Managers = /* @__PURE__ */ new Map();
7955
8016
  escalations = [];
7956
8017
  store;
8018
+ runBreaker;
7957
8019
  t2PeerBus = new PeerBus();
7958
8020
  permissionEscalator;
7959
8021
  toolCreator;
@@ -7968,6 +8030,10 @@ var T1Administrator = class extends BaseTier {
7968
8030
  this.toolRegistry = toolRegistry;
7969
8031
  this.config = config;
7970
8032
  }
8033
+ /** Share the run-wide circuit breaker with every section manager. */
8034
+ setRunBreaker(breaker) {
8035
+ this.runBreaker = breaker;
8036
+ }
7971
8037
  setStore(store) {
7972
8038
  this.store = store;
7973
8039
  }
@@ -8330,6 +8396,7 @@ SPEC RULES \u2014 each subtask is a self-contained spec slice (workers execute f
8330
8396
  };
8331
8397
  const managers = sections.map((section) => {
8332
8398
  const manager = new T2Manager(this.router, this.toolRegistry, this.id);
8399
+ if (this.runBreaker) manager.setRunBreaker(this.runBreaker);
8333
8400
  manager.setHierarchyContext(`You are a T2 Manager for the section "${section.sectionTitle}". You are part of a COMPLEX task overseen by T1 Administrator.`);
8334
8401
  if (this.store) {
8335
8402
  manager.setStore(this.store);
@@ -8615,6 +8682,158 @@ Reply with exactly one word: YES, NO, or UNSURE.
8615
8682
  }
8616
8683
  };
8617
8684
 
8685
+ // src/core/router/provider-errors.ts
8686
+ var SYSTEMIC = /* @__PURE__ */ new Set([
8687
+ "rate_limit",
8688
+ "quota_exhausted",
8689
+ "auth",
8690
+ "model_unavailable"
8691
+ ]);
8692
+ function isSystemicKind(kind) {
8693
+ return SYSTEMIC.has(kind);
8694
+ }
8695
+ function statusOf(err) {
8696
+ if (!err || typeof err !== "object") return void 0;
8697
+ const e = err;
8698
+ for (const key of ["status", "statusCode", "code"]) {
8699
+ const v = e[key];
8700
+ if (typeof v === "number" && v >= 100 && v < 600) return v;
8701
+ }
8702
+ const resp = e["response"];
8703
+ if (resp && typeof resp["status"] === "number") return resp["status"];
8704
+ const inner = e["error"];
8705
+ if (inner && typeof inner["code"] === "number") return inner["code"];
8706
+ return void 0;
8707
+ }
8708
+ function messageOf(err) {
8709
+ if (err instanceof Error) return err.message;
8710
+ if (typeof err === "string") return err;
8711
+ if (err && typeof err === "object") {
8712
+ const e = err;
8713
+ if (typeof e["message"] === "string") return e["message"];
8714
+ const inner = e["error"];
8715
+ if (inner && typeof inner["message"] === "string") return inner["message"];
8716
+ try {
8717
+ return JSON.stringify(err);
8718
+ } catch {
8719
+ }
8720
+ }
8721
+ return String(err);
8722
+ }
8723
+ function classifyProviderError(err) {
8724
+ const raw = messageOf(err).trim();
8725
+ const status = statusOf(err);
8726
+ const m = raw.toLowerCase();
8727
+ const kind = (() => {
8728
+ if (status === 429) {
8729
+ return /quota|billing|credit|exceeded your current quota|insufficient/.test(m) ? "quota_exhausted" : "rate_limit";
8730
+ }
8731
+ if (status === 401 || status === 403) return "auth";
8732
+ if (status === 404) return "model_unavailable";
8733
+ if (/rate limit|too many requests|resource_exhausted/.test(m)) {
8734
+ return /quota|billing|credit|insufficient/.test(m) ? "quota_exhausted" : "rate_limit";
8735
+ }
8736
+ if (/quota|billing|insufficient_quota|insufficient credit|payment required/.test(m)) return "quota_exhausted";
8737
+ if (/api key|unauthorized|authentication|permission denied|invalid[_ ]api/.test(m)) return "auth";
8738
+ if (/model .*(not found|does not exist|unavailable)|no such model|unknown model|not supported for/.test(m)) {
8739
+ return "model_unavailable";
8740
+ }
8741
+ if (/context length|too many tokens|maximum context|prompt is too long|input too long/.test(m)) return "context_length";
8742
+ if (/safety|content filter|blocked|recitation|prohibited/.test(m)) return "content_filter";
8743
+ if (/econnreset|etimedout|enotfound|socket hang up|network|fetch failed|aborted/.test(m)) return "network";
8744
+ return "unknown";
8745
+ })();
8746
+ return { kind, systemic: isSystemicKind(kind), status, raw };
8747
+ }
8748
+ function describeProviderError(c, modelId) {
8749
+ const on = ` on ${modelId}` ;
8750
+ const detail = c.raw ? ` Provider said: ${c.raw}` : "";
8751
+ switch (c.kind) {
8752
+ case "rate_limit":
8753
+ return `Rate limit hit${on}. The provider is throttling requests \u2014 lower the number of parallel workers, or use a model with a higher requests-per-minute allowance.${detail}`;
8754
+ case "quota_exhausted":
8755
+ return `Quota or billing limit reached${on}. This will not recover on its own \u2014 check the provider's billing page, or switch this tier to a different provider.${detail}`;
8756
+ case "auth":
8757
+ return `Authentication failed${on}. The API key is missing, expired, or lacks access to this model \u2014 re-check the key in Settings > Providers.${detail}`;
8758
+ case "model_unavailable":
8759
+ return `Model unavailable${on}. The id may be wrong, retired, or not enabled for this API key \u2014 pick a different model for this tier.${detail}`;
8760
+ case "context_length":
8761
+ return `The prompt was too long${on}. Reduce the attached context, or use a model with a larger context window for this tier.${detail}`;
8762
+ case "content_filter":
8763
+ return `The provider's safety filter refused this request${on}.${detail}`;
8764
+ case "network":
8765
+ return `Network error reaching the provider${on}. This is usually transient.${detail}`;
8766
+ default:
8767
+ return `The model call failed${on}.${detail}`;
8768
+ }
8769
+ }
8770
+
8771
+ // src/core/run-breaker.ts
8772
+ var DEFAULT_FAILURE_THRESHOLD = 3;
8773
+ var RunBreaker = class {
8774
+ threshold;
8775
+ /** modelId → consecutive systemic failures of the SAME kind. */
8776
+ counts = /* @__PURE__ */ new Map();
8777
+ trip = null;
8778
+ constructor(threshold = DEFAULT_FAILURE_THRESHOLD) {
8779
+ this.threshold = Math.max(1, threshold);
8780
+ }
8781
+ /**
8782
+ * Record a failed model call. Returns the trip if this failure is the one
8783
+ * that opened the breaker, otherwise null.
8784
+ *
8785
+ * A non-systemic failure RESETS the model's counter: it is evidence the model
8786
+ * is reachable and answering, which is exactly what a systemic streak claims
8787
+ * it is not.
8788
+ */
8789
+ record(err, modelId) {
8790
+ if (this.trip) return null;
8791
+ const id = modelId?.trim() || "unknown-model";
8792
+ const c = classifyProviderError(err);
8793
+ if (!c.systemic) {
8794
+ this.counts.delete(id);
8795
+ return null;
8796
+ }
8797
+ const prev = this.counts.get(id);
8798
+ const n = prev && prev.kind === c.kind ? prev.n + 1 : 1;
8799
+ this.counts.set(id, { kind: c.kind, n, last: c });
8800
+ if (n >= this.threshold) {
8801
+ this.trip = {
8802
+ modelId: id,
8803
+ kind: c.kind,
8804
+ failures: n,
8805
+ message: describeProviderError(c, id)
8806
+ };
8807
+ return this.trip;
8808
+ }
8809
+ return null;
8810
+ }
8811
+ /** True once the run should stop launching work that would fail identically. */
8812
+ isOpen() {
8813
+ return this.trip !== null;
8814
+ }
8815
+ /** Why the breaker opened, or null while it is closed. */
8816
+ reason() {
8817
+ return this.trip;
8818
+ }
8819
+ /**
8820
+ * One line for a worker/section that never ran because the breaker was
8821
+ * already open. Says the work was skipped deliberately — not that it failed
8822
+ * on its own merits, which is what an empty FAILED status would imply.
8823
+ */
8824
+ skipMessage() {
8825
+ const t = this.trip;
8826
+ if (!t) return "Skipped.";
8827
+ return `Skipped \u2014 the run stopped after ${t.failures} consecutive failures on ${t.modelId}. ${t.message}`;
8828
+ }
8829
+ /** Test/diagnostic view of the per-model streaks. */
8830
+ streaks() {
8831
+ const out = {};
8832
+ for (const [k, v] of this.counts) out[k] = { kind: v.kind, n: v.n };
8833
+ return out;
8834
+ }
8835
+ };
8836
+
8618
8837
  // src/tools/base.ts
8619
8838
  var BaseTool = class {
8620
8839
  workspaceRoot = process.cwd();
@@ -14187,6 +14406,7 @@ ${prompt}` : prompt;
14187
14406
  if (toolCreator) toolCreator.setPermissionEscalator(escalator);
14188
14407
  const rootPrompt = buildContextualPrompt(options.prompt, options.conversationHistory);
14189
14408
  let finalOutput = "";
14409
+ const runBreaker = new RunBreaker();
14190
14410
  let t2Results = [];
14191
14411
  let runError = null;
14192
14412
  let identityPrompt = "";
@@ -14255,6 +14475,7 @@ ${prompt}` : prompt;
14255
14475
  this.emit("tier:status", { tierId: "t3-root", status: "COMPLETED", role: "T3" });
14256
14476
  } else if (complexity === "Moderate") {
14257
14477
  const t2 = new T2Manager(this.router, this.toolRegistry, "root");
14478
+ t2.setRunBreaker(runBreaker);
14258
14479
  t2.setPresenter(true);
14259
14480
  t2.setHierarchyContext("You are the ROOT Manager for this task. There is no T1 Administrator involved in this run. You are responsible for decomposing the task and managing T3 workers directly.");
14260
14481
  if (identityPrompt) {
@@ -14315,6 +14536,7 @@ _(incomplete: ${reasons.join("; ")})_` : partial;
14315
14536
  }
14316
14537
  } else {
14317
14538
  const t1 = new T1Administrator(this.router, this.toolRegistry, this.config);
14539
+ t1.setRunBreaker(runBreaker);
14318
14540
  t1.setPresenter(true);
14319
14541
  t1.setHierarchyContext("You are the top-level Administrator. You are responsible for the overall plan and supervising multiple T2 Managers.");
14320
14542
  if (identityPrompt) {
@@ -14339,6 +14561,20 @@ _(incomplete: ${reasons.join("; ")})_` : partial;
14339
14561
  finalOutput = result.output;
14340
14562
  t2Results = result.t2Results;
14341
14563
  }
14564
+ const trip = runBreaker.reason();
14565
+ if (trip) {
14566
+ finalOutput = [
14567
+ `\u26A0 **Run stopped early \u2014 ${trip.modelId} was failing every request.**`,
14568
+ "",
14569
+ trip.message,
14570
+ "",
14571
+ `Cascade stopped after ${trip.failures} consecutive failures rather than retrying every remaining subtask against the same model, which would have cost more and produced the same result.`,
14572
+ finalOutput ? `
14573
+ ---
14574
+
14575
+ ${finalOutput}` : ""
14576
+ ].join("\n").trimEnd();
14577
+ }
14342
14578
  } catch (err) {
14343
14579
  if (err instanceof CascadeCancelledError || err instanceof Error && err.name === "AbortError" || options.signal?.aborted) {
14344
14580
  this.emit("run:cancelled", {