cascade-ai 0.68.0 → 0.69.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.js CHANGED
@@ -1862,7 +1862,7 @@ function reconcilePrice(model, live) {
1862
1862
  }
1863
1863
 
1864
1864
  // src/constants.ts
1865
- var CASCADE_VERSION = "0.68.0";
1865
+ var CASCADE_VERSION = "0.69.0";
1866
1866
  var CASCADE_CONFIG_DIR = ".cascade";
1867
1867
  var CASCADE_MD_FILE = "CASCADE.md";
1868
1868
  var CASCADE_IGNORE_FILE = ".cascadeignore";
@@ -6024,6 +6024,21 @@ var BaseTier = class extends EventEmitter {
6024
6024
  * which node (Cockpit node panel / Why panel).
6025
6025
  */
6026
6026
  servingModel;
6027
+ /**
6028
+ * This tier's identity IN THE TASK GRAPH — the section or subtask id the
6029
+ * planner assigned — plus where it sat in the dependency order.
6030
+ *
6031
+ * `id` and `graphNodeId` are deliberately different id spaces, and both are
6032
+ * needed. `id` identifies this tier INSTANCE ("T2_a1b2c3d4", minted per
6033
+ * construction); `graphNodeId` identifies the WORK ("s1"), which is what
6034
+ * dependency edges point at. Without the distinction a graph view cannot be
6035
+ * drawn at all: `dependsOn` lists section ids, while every running tier
6036
+ * reports a generated tier id, so every edge would name a node that never
6037
+ * appears in the stream.
6038
+ */
6039
+ graphNodeId;
6040
+ graphDependsOn;
6041
+ graphWave;
6027
6042
  constructor(role, id, parentId) {
6028
6043
  super();
6029
6044
  this.role = role;
@@ -6035,6 +6050,33 @@ var BaseTier = class extends EventEmitter {
6035
6050
  setPresenter(on = true) {
6036
6051
  this.isPresenter = on;
6037
6052
  }
6053
+ /**
6054
+ * Record where this tier sits in the compiled task graph.
6055
+ *
6056
+ * Called by whichever tier scheduled this one: the id and edges are known at
6057
+ * construction, the wave only once the scheduler reaches it, so the fields
6058
+ * are set independently rather than all at once.
6059
+ */
6060
+ setGraphPosition(position) {
6061
+ if (position.nodeId !== void 0) this.graphNodeId = position.nodeId;
6062
+ if (position.dependsOn !== void 0) this.graphDependsOn = [...position.dependsOn];
6063
+ if (position.wave !== void 0) this.graphWave = position.wave;
6064
+ }
6065
+ /**
6066
+ * The graph fields, for spreading into a status payload.
6067
+ *
6068
+ * One helper rather than two literals because `tier:status` is emitted from
6069
+ * two different call sites with two different payload shapes; adding a field
6070
+ * to only one of them makes it arrive intermittently, which is worse for a
6071
+ * consumer than never arriving at all.
6072
+ */
6073
+ graphFields() {
6074
+ return {
6075
+ nodeId: this.graphNodeId,
6076
+ dependsOn: this.graphDependsOn ? [...this.graphDependsOn] : void 0,
6077
+ waveId: this.graphWave
6078
+ };
6079
+ }
6038
6080
  getStatus() {
6039
6081
  return this.status;
6040
6082
  }
@@ -6049,7 +6091,8 @@ var BaseTier = class extends EventEmitter {
6049
6091
  status,
6050
6092
  timestamp,
6051
6093
  output,
6052
- model: this.servingModel
6094
+ model: this.servingModel,
6095
+ ...this.graphFields()
6053
6096
  };
6054
6097
  this.emit("status", event);
6055
6098
  this.emit("tier:status", event);
@@ -6089,7 +6132,8 @@ var BaseTier = class extends EventEmitter {
6089
6132
  progressPct: update.progressPct,
6090
6133
  timestamp,
6091
6134
  output: update.output,
6092
- model: this.servingModel
6135
+ model: this.servingModel,
6136
+ ...this.graphFields()
6093
6137
  });
6094
6138
  }
6095
6139
  buildMessage(type, to, payload) {
@@ -6991,7 +7035,8 @@ Now execute your subtask using this context where relevant.`
6991
7035
  if (result2.verdict === "passed") passed.push(result2.criterion);
6992
7036
  }
6993
7037
  this.sendStatusUpdate({ progressPct: 70, currentAction: "Self-testing output", status: "IN_PROGRESS" });
6994
- const testResult = await this.selfTest(assignment, output, undecided(acceptanceResults));
7038
+ const pendingAcceptance = undecided(acceptanceResults);
7039
+ const testResult = await this.selfTest(assignment, output, pendingAcceptance);
6995
7040
  checksRun.push(...testResult.checksRun);
6996
7041
  passed.push(...testResult.passed);
6997
7042
  failed.push(...testResult.failed);
@@ -7000,7 +7045,7 @@ Now execute your subtask using this context where relevant.`
7000
7045
  issues.push(`Initial check failed: ${testResult.failed.join(", ")}`);
7001
7046
  const corrected = await this.correctOutput(output, testResult.failed);
7002
7047
  output = corrected;
7003
- const retest = await this.selfTest(assignment, output);
7048
+ const retest = await this.selfTest(assignment, output, pendingAcceptance);
7004
7049
  passed.push(...retest.passed);
7005
7050
  if (retest.failed.length > 0) {
7006
7051
  failed.push(...retest.failed);
@@ -8924,6 +8969,7 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
8924
8969
  for (const a of assignments) {
8925
8970
  const worker = new T3Worker(this.router, this.toolRegistry, this.id);
8926
8971
  if (this.store) worker.setStore(this.store, taskId);
8972
+ worker.setGraphPosition({ nodeId: a.subtaskId, dependsOn: a.dependsOn ?? [] });
8927
8973
  worker.setPeerBus(this.t3PeerBus);
8928
8974
  if (this.permissionEscalator) worker.setPermissionEscalator(this.permissionEscalator);
8929
8975
  if (this.toolCreator) worker.setToolCreator(this.toolCreator);
@@ -9044,6 +9090,7 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
9044
9090
  runnableIds.push(fallbackId);
9045
9091
  }
9046
9092
  wave++;
9093
+ for (const id of runnableIds) workerMap.get(id)?.setGraphPosition({ wave });
9047
9094
  this.log(`Wave ${wave}: running ${runnableIds.length} subtask(s) in parallel`);
9048
9095
  this.sendStatusUpdate({
9049
9096
  progressPct: 20 + Math.min(wave * 10, 60),
@@ -9371,23 +9418,63 @@ var DependencyScheduler = class {
9371
9418
  dependents.get(dependencyId)?.push(node.id);
9372
9419
  }
9373
9420
  }
9374
- const completed = /* @__PURE__ */ new Set();
9421
+ const policy = this.options.onUpstreamFailure ?? "continue";
9422
+ const settled = /* @__PURE__ */ new Set();
9375
9423
  const results = /* @__PURE__ */ new Map();
9424
+ const blocked = /* @__PURE__ */ new Map();
9376
9425
  const waves = [];
9377
9426
  let wave = 0;
9378
- while (completed.size < nodes.length) {
9379
- const ready = nodes.filter((node) => !completed.has(node.id) && (inDegree.get(node.id) ?? 0) === 0);
9427
+ const blockDescendants = async (failedIds) => {
9428
+ const rootsFor = /* @__PURE__ */ new Map();
9429
+ const queue = [];
9430
+ for (const rootId of failedIds) {
9431
+ for (const childId of dependents.get(rootId) ?? []) queue.push({ id: childId, root: rootId });
9432
+ }
9433
+ while (queue.length > 0) {
9434
+ const { id, root } = queue.shift();
9435
+ if (settled.has(id)) continue;
9436
+ const known = rootsFor.get(id);
9437
+ if (known) {
9438
+ if (!known.has(root)) known.add(root);
9439
+ continue;
9440
+ }
9441
+ rootsFor.set(id, /* @__PURE__ */ new Set([root]));
9442
+ for (const childId of dependents.get(id) ?? []) queue.push({ id: childId, root });
9443
+ }
9444
+ for (const node of nodes) {
9445
+ const roots = rootsFor.get(node.id);
9446
+ if (!roots || settled.has(node.id)) continue;
9447
+ const causes = nodes.filter((n) => roots.has(n.id));
9448
+ const blockedBy = causes.map((n) => n.id);
9449
+ const blockedByTitles = causes.map((n) => n.title || n.id);
9450
+ const record = {
9451
+ blockedBy,
9452
+ blockedByTitles,
9453
+ reason: blockedByTitles.length === 1 ? `Required upstream work "${blockedByTitles[0]}" failed` : `Required upstream work ${blockedByTitles.map((t) => `"${t}"`).join(", ")} failed`
9454
+ };
9455
+ blocked.set(node.id, record);
9456
+ settled.add(node.id);
9457
+ await this.hooks.onNodeBlocked?.(node, record);
9458
+ }
9459
+ };
9460
+ while (settled.size < nodes.length) {
9461
+ const ready = nodes.filter((node) => !settled.has(node.id) && (inDegree.get(node.id) ?? 0) === 0);
9380
9462
  if (ready.length === 0) {
9381
9463
  throw new Error("Dependency scheduler received a cyclic or incomplete task graph");
9382
9464
  }
9383
9465
  waves.push(ready.map((node) => node.id));
9384
9466
  await this.hooks.onWaveStart?.(ready, wave);
9467
+ const outcomes = /* @__PURE__ */ new Map();
9385
9468
  const runOne = async (node) => {
9386
9469
  const result = await this.hooks.execute(node);
9387
9470
  results.set(node.id, result);
9388
- completed.add(node.id);
9389
- for (const dependentId of dependents.get(node.id) ?? []) {
9390
- inDegree.set(dependentId, Math.max(0, (inDegree.get(dependentId) ?? 1) - 1));
9471
+ settled.add(node.id);
9472
+ const outcome = this.hooks.classify?.(node, result) ?? "succeeded";
9473
+ outcomes.set(node.id, outcome);
9474
+ if (outcome === "failed" && policy === "block") ; else {
9475
+ for (const dependentId of dependents.get(node.id) ?? []) {
9476
+ inDegree.set(dependentId, Math.max(0, (inDegree.get(dependentId) ?? 1) - 1));
9477
+ }
9391
9478
  }
9392
9479
  await this.hooks.onNodeComplete?.(node, result);
9393
9480
  };
@@ -9396,9 +9483,13 @@ var DependencyScheduler = class {
9396
9483
  } else {
9397
9484
  await Promise.all(ready.map(runOne));
9398
9485
  }
9486
+ const failedThisWave = ready.filter((node) => outcomes.get(node.id) === "failed").map((node) => node.id);
9487
+ if (policy === "block" && failedThisWave.length > 0) {
9488
+ await blockDescendants(failedThisWave);
9489
+ }
9399
9490
  wave++;
9400
9491
  }
9401
- return { results, waves };
9492
+ return { results, waves, blocked };
9402
9493
  }
9403
9494
  };
9404
9495
 
@@ -9499,6 +9590,9 @@ QUALITY RULES:
9499
9590
 
9500
9591
  ${generation}` : ""}`;
9501
9592
  }
9593
+ function producedOutput(result) {
9594
+ return result.status === "COMPLETED" || result.status === "PARTIAL";
9595
+ }
9502
9596
  var T1Administrator = class extends BaseTier {
9503
9597
  router;
9504
9598
  toolRegistry;
@@ -9633,7 +9727,7 @@ Board guidance (must be followed in the plan): ${decision.note}`,
9633
9727
  let allT2Results = await this.dispatchT2Managers(plan.sections);
9634
9728
  let pass = 1;
9635
9729
  const maxReplanPasses = this.config.maxReplanPasses ?? 2;
9636
- const okCount = (rs) => rs.filter((r) => r.status === "COMPLETED" || r.status === "PARTIAL").length;
9730
+ const okCount = (rs) => rs.filter(producedOutput).length;
9637
9731
  while (pass <= maxReplanPasses) {
9638
9732
  const reviewResult = await this.reviewT2Outputs(enrichedPrompt, plan, allT2Results);
9639
9733
  if (reviewResult.approved) {
@@ -9764,7 +9858,7 @@ In 3-5 terse bullets, flag the most important RISKS, GAPS, or over-/under-decomp
9764
9858
  /** Structured, grounded summary of what's already done — used to keep
9765
9859
  * corrective replan passes from re-emitting completed sections. */
9766
9860
  summarizeCompletedSections(results) {
9767
- const done = results.filter((r) => r.status === "COMPLETED" || r.status === "PARTIAL");
9861
+ const done = results.filter(producedOutput);
9768
9862
  if (done.length === 0) return "";
9769
9863
  const lines = done.map((r) => `- "${r.sectionTitle}" [${r.status}]: ${(r.sectionSummary || "(no summary)").slice(0, 300)}`);
9770
9864
  return `ALREADY COMPLETED SECTIONS (do not redo these \u2014 plan only what's still missing):
@@ -9909,6 +10003,7 @@ SPEC RULES \u2014 each subtask is a self-contained spec slice (workers execute f
9909
10003
  if (this.runBreaker) manager.setRunBreaker(this.runBreaker);
9910
10004
  if (this.escalationCallback) manager.setEscalationCallback(this.escalationCallback);
9911
10005
  manager.setHierarchyContext(`You are a T2 Manager for the section "${section.sectionTitle}". You are part of a COMPLEX task overseen by T1 Administrator.`);
10006
+ manager.setGraphPosition({ nodeId: section.sectionId, dependsOn: section.dependsOn ?? [] });
9912
10007
  if (this.store) {
9913
10008
  manager.setStore(this.store);
9914
10009
  }
@@ -10035,6 +10130,13 @@ SPEC RULES \u2014 each subtask is a self-contained spec slice (workers execute f
10035
10130
  const scheduler = new DependencyScheduler(
10036
10131
  compiled.graph,
10037
10132
  {
10133
+ // The wave is the scheduler's own counter, and it is the only place the
10134
+ // parallelism is visible: nodes sharing a wave ran at the same time.
10135
+ // Stamped before the wave executes so the first status event a node
10136
+ // emits already carries it.
10137
+ onWaveStart: (nodes, wave) => {
10138
+ for (const node of nodes) managers[node.ordinal]?.setGraphPosition({ wave });
10139
+ },
10038
10140
  execute: async (node) => {
10039
10141
  const section = node.payload;
10040
10142
  const manager = managers[node.ordinal];
@@ -10069,6 +10171,62 @@ SPEC RULES \u2014 each subtask is a self-contained spec slice (workers execute f
10069
10171
  };
10070
10172
  }
10071
10173
  },
10174
+ // PARTIAL is deliberately not a failure: a degraded-but-real section
10175
+ // can still feed the one after it, and cancelling that work would cost
10176
+ // the user more than letting it try.
10177
+ // BLOCKED cannot reach here today — the scheduler settles blocked nodes
10178
+ // through onNodeBlocked and never executes them, so classify only ever
10179
+ // sees results that ran. It is listed anyway because the failure mode if
10180
+ // that ever changes is silent and expensive: a blocked section would
10181
+ // classify as 'succeeded' and release its own dependents, so work that
10182
+ // should have been skipped would run and be billed.
10183
+ classify: (_node, result) => result.status === "FAILED" || result.status === "BLOCKED" ? "failed" : "succeeded",
10184
+ onNodeBlocked: (node, blockedInfo) => {
10185
+ const section = node.payload;
10186
+ this.log(`\u292B Skipped "${section.sectionTitle}" \u2014 ${blockedInfo.reason}`);
10187
+ resultMap.set(node.id, {
10188
+ sectionId: section.sectionId,
10189
+ sectionTitle: section.sectionTitle,
10190
+ // BLOCKED, not FAILED. This section was never attempted — saying it
10191
+ // failed reports a breakage that did not happen, and hides the one
10192
+ // that did (the upstream section named below).
10193
+ status: "BLOCKED",
10194
+ t3Results: [],
10195
+ sectionSummary: "",
10196
+ issues: [
10197
+ `${blockedInfo.reason}. This section was not attempted, so no tokens were spent on it.`
10198
+ ],
10199
+ blockedBy: { ids: [...blockedInfo.blockedBy], titles: [...blockedInfo.blockedByTitles] }
10200
+ });
10201
+ completedSections++;
10202
+ this.sendStatusUpdate({
10203
+ progressPct: 10 + Math.floor(completedSections / totalSections * 85),
10204
+ currentAction: `Skipped: ${section.sectionTitle} (blocked by an upstream failure)`,
10205
+ status: "IN_PROGRESS"
10206
+ });
10207
+ this.emit("tier:status", {
10208
+ tierId: section.sectionId,
10209
+ // A blocked section has no tier instance — it was never
10210
+ // constructed — so its runtime id and its graph id are the same
10211
+ // thing here. Emitting nodeId anyway keeps every node in the
10212
+ // stream addressable in the graph's id space, which is what
10213
+ // dependsOn edges point at.
10214
+ nodeId: section.sectionId,
10215
+ dependsOn: [...section.dependsOn ?? []],
10216
+ role: "T2",
10217
+ label: section.sectionTitle,
10218
+ status: "BLOCKED",
10219
+ blockedBy: [...blockedInfo.blockedByTitles],
10220
+ output: blockedInfo.reason
10221
+ });
10222
+ this.emit("section:complete", {
10223
+ id: node.id,
10224
+ title: section.sectionTitle,
10225
+ status: "BLOCKED",
10226
+ blockedBy: [...blockedInfo.blockedByTitles],
10227
+ output: ""
10228
+ });
10229
+ },
10072
10230
  onNodeComplete: (node, result) => {
10073
10231
  resultMap.set(node.id, result);
10074
10232
  completedSections++;
@@ -10085,14 +10243,18 @@ SPEC RULES \u2014 each subtask is a self-contained spec slice (workers execute f
10085
10243
  // intra-section T3 wave (t2-manager.ts) — 'sequential' otherwise only
10086
10244
  // serialized T3 workers within a single section while every section's
10087
10245
  // T2Manager (and its T3 workers) still ran in parallel here.
10088
- mode: this.router.getT3ExecutionMode?.() === "sequential" ? "sequential" : "parallel"
10246
+ mode: this.router.getT3ExecutionMode?.() === "sequential" ? "sequential" : "parallel",
10247
+ // A section that declared `dependsOn` said it needs that section's
10248
+ // output. Running it after the dependency failed produces a second
10249
+ // failure, billed, whose cause is the first one — so don't.
10250
+ onUpstreamFailure: "block"
10089
10251
  }
10090
10252
  );
10091
10253
  await scheduler.run();
10092
10254
  return sections.map((s) => resultMap.get(s.sectionId)).filter(Boolean);
10093
10255
  }
10094
10256
  async compileFinalOutput(originalPrompt, plan, t2Results) {
10095
- const completedSections = t2Results.filter((r) => r.status !== "FAILED");
10257
+ const completedSections = t2Results.filter(producedOutput);
10096
10258
  if (!completedSections.length) {
10097
10259
  const allIssues = [
10098
10260
  ...t2Results.flatMap((r) => r.issues ?? []),
@@ -10112,7 +10274,6 @@ ${r.sectionSummary}
10112
10274
  Outputs:
10113
10275
  ${r.t3Results.filter((t) => t.status === "COMPLETED").map((t) => `\u2022 ${typeof t.output === "string" ? t.output : JSON.stringify(t.output)}`).join("\n")}`).join("\n\n---\n\n");
10114
10276
  const openIssues = t2Results.flatMap((r) => r.issues).filter(Boolean);
10115
- t2Results.filter((r) => r.status === "FAILED" || r.status === "PARTIAL");
10116
10277
  const compilePrompt = `Compile a final, coherent response to the user's original request.
10117
10278
 
10118
10279
  Original request: ${originalPrompt}
@@ -10604,6 +10765,7 @@ Model: ${asset.modelId} \u2014 ${r.selected.reason}`;
10604
10765
  };
10605
10766
  var DEFAULT_VIDEO_SECONDS = 5;
10606
10767
  var MAX_VIDEO_SECONDS = 8;
10768
+ var MIN_VIDEO_SECONDS = 4;
10607
10769
  var GenerateVideoTool = class extends BaseTool {
10608
10770
  constructor(deps) {
10609
10771
  super();
@@ -10620,7 +10782,7 @@ var GenerateVideoTool = class extends BaseTool {
10620
10782
  type: "object",
10621
10783
  properties: {
10622
10784
  prompt: { type: "string", description: "What the video should show: subject, action, camera movement, style." },
10623
- seconds: { type: "number", description: `Clip length, 1-${MAX_VIDEO_SECONDS} seconds (default ${DEFAULT_VIDEO_SECONDS}). Billed per second \u2014 keep it short.` },
10785
+ seconds: { type: "number", description: `Clip length, ${MIN_VIDEO_SECONDS}-${MAX_VIDEO_SECONDS} seconds (default ${DEFAULT_VIDEO_SECONDS}). Billed per second \u2014 keep it short.` },
10624
10786
  aspectRatio: { type: "string", description: 'Frame shape: "16:9" (default) or "9:16" for vertical.' },
10625
10787
  model: { type: "string", description: "Optional: force a specific video model." }
10626
10788
  },
@@ -10632,7 +10794,7 @@ var GenerateVideoTool = class extends BaseTool {
10632
10794
  const r = resolve(this.deps, "video", input["model"] ? String(input["model"]) : void 0);
10633
10795
  if (!r.ok) return r.error;
10634
10796
  const asked = typeof input["seconds"] === "number" ? input["seconds"] : DEFAULT_VIDEO_SECONDS;
10635
- const seconds = Math.max(1, Math.min(MAX_VIDEO_SECONDS, Math.round(asked)));
10797
+ const seconds = Math.max(MIN_VIDEO_SECONDS, Math.min(MAX_VIDEO_SECONDS, Math.round(asked)));
10636
10798
  const asset = await generateVideo(
10637
10799
  r.selected.capability,
10638
10800
  r.cfg,
@@ -10649,7 +10811,7 @@ var GenerateVideoTool = class extends BaseTool {
10649
10811
  const location = await this.deps.sink(asset);
10650
10812
  const unit = r.selected.cost;
10651
10813
  const estimate = unit ? ` Estimated cost: $${(unit.amount * seconds).toFixed(2)} (${seconds}s x ${unit.label}).` : "";
10652
- const clamped = seconds !== Math.round(asked) ? ` Duration was clamped to ${seconds}s (max ${MAX_VIDEO_SECONDS}).` : "";
10814
+ const clamped = seconds !== Math.round(asked) ? ` Duration was clamped to ${seconds}s (allowed ${MIN_VIDEO_SECONDS}-${MAX_VIDEO_SECONDS}).` : "";
10653
10815
  return `Video generated and saved to ${location}.
10654
10816
  Model: ${asset.modelId}.${estimate}${clamped}`;
10655
10817
  }
@@ -14403,7 +14565,24 @@ var TaskAnalyzer = class {
14403
14565
  feedback;
14404
14566
  bias;
14405
14567
  lastProfile = null;
14406
- lastSelectedModels = /* @__PURE__ */ new Map();
14568
+ /** Models chosen by the run currently in flight. Cleared when it completes. */
14569
+ currentRunSelections = /* @__PURE__ */ new Map();
14570
+ /**
14571
+ * Immutable snapshot of the last COMPLETED run: its selections AND the task
14572
+ * type they were chosen for.
14573
+ *
14574
+ * The task type has to travel WITH the selections. `lastProfile` is overwritten
14575
+ * by the next analyze(), so if run B started before the user rated run A, A's
14576
+ * models were credited under B's task type — teaching the router that a coding
14577
+ * model is good at creative writing, from a rating that never said so.
14578
+ *
14579
+ * Explicit ratings arrive after the run finishes — rateLastRun() is by nature
14580
+ * a reaction to a finished answer — but the run finalizer calls
14581
+ * recordRunOutcome() first, and that used to clear the only map there was. So
14582
+ * every explicit rating iterated an empty map, recorded nothing, and returned
14583
+ * false: the 3x-weighted user signal never reached the tracker at all.
14584
+ */
14585
+ lastCompletedRun;
14407
14586
  constructor(tracker, bias = "balanced") {
14408
14587
  this.tracker = tracker;
14409
14588
  this.bias = bias;
@@ -14450,11 +14629,15 @@ var TaskAnalyzer = class {
14450
14629
  */
14451
14630
  async selectModel(prompt, tier, selector, opts) {
14452
14631
  const profile = await this.analyze(prompt);
14632
+ const recordSelection = (model) => {
14633
+ if (model) this.currentRunSelections.set(tier, model);
14634
+ return model;
14635
+ };
14453
14636
  if (profile.requiresVision) {
14454
- return selector.selectVisionModel();
14637
+ return recordSelection(selector.selectVisionModel());
14455
14638
  }
14456
14639
  let candidates = selector.getCandidatesForTier(tier);
14457
- if (candidates.length === 0) return selector.selectForTier(tier);
14640
+ if (candidates.length === 0) return recordSelection(selector.selectForTier(tier));
14458
14641
  if (opts?.requiresToolUse) {
14459
14642
  const toolCapable = candidates.filter((m) => m.supportsToolUse !== false);
14460
14643
  if (toolCapable.length > 0) candidates = toolCapable;
@@ -14464,9 +14647,7 @@ var TaskAnalyzer = class {
14464
14647
  score: this.scoreModel(m, profile)
14465
14648
  }));
14466
14649
  scored.sort((a, b) => b.score - a.score);
14467
- const best = scored[0]?.model ?? selector.selectForTier(tier);
14468
- if (best) this.lastSelectedModels.set(tier, best);
14469
- return best;
14650
+ return recordSelection(scored[0]?.model ?? selector.selectForTier(tier));
14470
14651
  }
14471
14652
  /**
14472
14653
  * Record the outcome of a completed run across all tiers that were selected
@@ -14475,25 +14656,38 @@ var TaskAnalyzer = class {
14475
14656
  recordRunOutcome(outcome, costByTier, contextTokens = 0) {
14476
14657
  if (!this.tracker || !this.lastProfile) return;
14477
14658
  const taskType = this.lastProfile.type;
14478
- for (const [tier, model] of this.lastSelectedModels) {
14659
+ for (const [tier, model] of this.currentRunSelections) {
14479
14660
  const cost = costByTier[tier] ?? 0;
14480
14661
  this.tracker.record(model.id, taskType, outcome, 0, cost, contextTokens);
14481
14662
  }
14482
- this.lastSelectedModels.clear();
14663
+ this.lastCompletedRun = { selections: new Map(this.currentRunSelections), taskType };
14664
+ this.currentRunSelections.clear();
14483
14665
  void this.tracker.save();
14484
14666
  }
14485
14667
  /**
14486
- * Record an explicit user rating (good/bad) for the last run's selected models.
14668
+ * Record an explicit user rating (good/bad) for the last COMPLETED run's models.
14487
14669
  * Explicit ratings carry 3× the weight of auto-detected outcomes.
14488
- * Does NOT clear lastSelectedModels — the auto record already did that.
14670
+ *
14671
+ * Reads the completed-run snapshot, not the in-flight map: by the time a user
14672
+ * can rate an answer, the finalizer has already run and the in-flight map is
14673
+ * empty.
14674
+ *
14675
+ * The snapshot is CONSUMED. recordExplicit() weights a rating 3x by recording
14676
+ * three samples, so a double submit would inject six — a stutter on the
14677
+ * button would count as two opinions. Consuming makes a second call a no-op
14678
+ * that reports false, which is what "rate this run" should mean.
14489
14679
  */
14490
14680
  recordExplicitRating(rating) {
14491
- if (!this.tracker || !this.lastProfile) return false;
14492
- const taskType = this.lastProfile.type;
14493
- for (const [, model] of this.lastSelectedModels) {
14494
- this.tracker.recordExplicit(model.id, taskType, rating, 0);
14495
- }
14496
- return this.lastSelectedModels.size > 0;
14681
+ const snapshot = this.lastCompletedRun;
14682
+ if (!this.tracker || !snapshot || snapshot.selections.size === 0) return false;
14683
+ const ratedModels = /* @__PURE__ */ new Set();
14684
+ for (const [, model] of snapshot.selections) {
14685
+ if (ratedModels.has(model.id)) continue;
14686
+ ratedModels.add(model.id);
14687
+ this.tracker.recordExplicit(model.id, snapshot.taskType, rating, 0);
14688
+ }
14689
+ this.lastCompletedRun = void 0;
14690
+ return true;
14497
14691
  }
14498
14692
  scoreModel(model, profile) {
14499
14693
  const perf = this.tracker?.performanceScore(model.id, profile.type) ?? 0.5;
@@ -15531,6 +15725,8 @@ var RESUME_SCHEMA_VERSION = 1;
15531
15725
  var DEFAULT_MAX_CHECKPOINTS = 5;
15532
15726
  var DEFAULT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1e3;
15533
15727
  var FILE_PATTERN = /^(\d+)-(.+)\.json$/;
15728
+ var CLAIM_SUFFIX = ".claimed";
15729
+ var DEFAULT_CLAIM_TIMEOUT_MS = 10 * 60 * 1e3;
15534
15730
  function safeId(taskId) {
15535
15731
  const cleaned = taskId.replace(/[^A-Za-z0-9._-]/g, "_").replace(/\.{2,}/g, "_");
15536
15732
  return cleaned.slice(0, 100) || "run";
@@ -15545,16 +15741,24 @@ var ResumeStore = class {
15545
15741
  maxCheckpoints;
15546
15742
  maxAgeMs;
15547
15743
  now;
15744
+ claimTimeoutMs;
15548
15745
  constructor(options) {
15549
15746
  this.dir = options.dir;
15550
15747
  this.maxCheckpoints = options.maxCheckpoints ?? DEFAULT_MAX_CHECKPOINTS;
15551
15748
  this.maxAgeMs = options.maxAgeMs ?? DEFAULT_MAX_AGE_MS;
15552
15749
  this.now = options.now ?? Date.now;
15750
+ this.claimTimeoutMs = options.claimTimeoutMs ?? DEFAULT_CLAIM_TIMEOUT_MS;
15553
15751
  }
15554
15752
  /**
15555
- * Persist a checkpoint and prune. Never throws: failing to save a resume file
15556
- * must not turn a recoverable stop into a crash, which would be a strictly
15557
- * worse outcome than the one this store exists to improve.
15753
+ * Persist a checkpoint and prune.
15754
+ *
15755
+ * Never throws — failing to save a resume file must not turn a recoverable
15756
+ * stop into a crash — but DOES report whether the atomic rename landed.
15757
+ * Callers decide whether an original claim is safe to discard based on that
15758
+ * answer, so "I swallowed the error" and "the checkpoint is on disk" must not
15759
+ * look alike to them.
15760
+ *
15761
+ * @returns true only when the checkpoint is durably on disk.
15558
15762
  */
15559
15763
  async save(checkpoint) {
15560
15764
  try {
@@ -15570,7 +15774,9 @@ var ResumeStore = class {
15570
15774
  await promises.writeFile(tempPath, JSON.stringify(record, null, 2), "utf-8");
15571
15775
  await promises.rename(tempPath, finalPath);
15572
15776
  await this.prune();
15777
+ return true;
15573
15778
  } catch {
15779
+ return false;
15574
15780
  }
15575
15781
  }
15576
15782
  /** Valid, unexpired checkpoints, newest first. */
@@ -15600,15 +15806,87 @@ var ResumeStore = class {
15600
15806
  return (await this.list())[0] ?? null;
15601
15807
  }
15602
15808
  /**
15603
- * Load a checkpoint and delete it in the same step. Resuming twice from one
15604
- * checkpoint would re-run restored work, so consumption is part of reading.
15809
+ * Claim a checkpoint for a resume attempt.
15810
+ *
15811
+ * This used to DELETE the file and return it, which opened a window with no
15812
+ * recovery record at all: between preparing the continuation and the resumed
15813
+ * run writing its own checkpoint, a crash lost the only copy — and a crash is
15814
+ * precisely what this store exists to survive. Deleting on read optimised for
15815
+ * "never resume twice" at the cost of "never lose the work", which is the
15816
+ * wrong trade for a recovery mechanism.
15817
+ *
15818
+ * Instead the file is RENAMED to `.claimed`, which still hides it from
15819
+ * `list()` (so a concurrent resume cannot take the same work) but keeps it on
15820
+ * disk. The caller then either `release`s it — restoring it for another
15821
+ * attempt — or `settle`s it once the resumed run is safely under way. A claim
15822
+ * abandoned by a dead process is reclaimed after `claimTimeoutMs`.
15605
15823
  */
15606
- async consume(taskId) {
15824
+ async claim(taskId) {
15607
15825
  const all = await this.list();
15608
15826
  const target = taskId ? all.find((c) => c.taskId === taskId) : all[0];
15609
15827
  if (!target) return null;
15610
- await this.delete(target.taskId);
15611
- return target;
15828
+ try {
15829
+ const entries = await promises.readdir(this.dir);
15830
+ const wanted = safeId(target.taskId);
15831
+ const source = entries.find((name) => FILE_PATTERN.exec(name)?.[2] === wanted);
15832
+ if (!source) return null;
15833
+ const claimId = `${this.now()}-${safeId(target.taskId)}`;
15834
+ await promises.rename(path26.join(this.dir, source), path26.join(this.dir, `${claimId}${CLAIM_SUFFIX}`));
15835
+ return { checkpoint: target, claimId };
15836
+ } catch {
15837
+ return null;
15838
+ }
15839
+ }
15840
+ /** Give a claim back, so the work stays recoverable after a failed start. */
15841
+ async release(claimId) {
15842
+ try {
15843
+ await promises.rename(
15844
+ path26.join(this.dir, `${claimId}${CLAIM_SUFFIX}`),
15845
+ path26.join(this.dir, `${claimId}.json`)
15846
+ );
15847
+ } catch {
15848
+ }
15849
+ }
15850
+ /** Discard a claim for good — the resumed run has taken ownership. */
15851
+ async settle(claimId) {
15852
+ try {
15853
+ await promises.rm(path26.join(this.dir, `${claimId}${CLAIM_SUFFIX}`), { force: true });
15854
+ } catch {
15855
+ }
15856
+ }
15857
+ /**
15858
+ * Claim-and-discard in one step. Kept for callers that genuinely want the old
15859
+ * semantics; prefer claim/settle so a crash mid-resume stays recoverable.
15860
+ */
15861
+ async consume(taskId) {
15862
+ const claimed = await this.claim(taskId);
15863
+ if (!claimed) return null;
15864
+ await this.settle(claimed.claimId);
15865
+ return claimed.checkpoint;
15866
+ }
15867
+ /**
15868
+ * Return claims abandoned by a process that died mid-resume. Without this a
15869
+ * crash between claim and settle would strand the checkpoint invisibly —
15870
+ * trading one loss window for another.
15871
+ */
15872
+ async reclaimStale() {
15873
+ let reclaimed = 0;
15874
+ try {
15875
+ const entries = await promises.readdir(this.dir);
15876
+ const cutoff = this.now() - this.claimTimeoutMs;
15877
+ for (const name of entries) {
15878
+ if (!name.endsWith(CLAIM_SUFFIX)) continue;
15879
+ const stamp2 = Number(/^(\d+)-/.exec(name)?.[1]);
15880
+ if (!Number.isFinite(stamp2) || stamp2 >= cutoff) continue;
15881
+ await promises.rename(
15882
+ path26.join(this.dir, name),
15883
+ path26.join(this.dir, `${name.slice(0, -CLAIM_SUFFIX.length)}.json`)
15884
+ );
15885
+ reclaimed++;
15886
+ }
15887
+ } catch {
15888
+ }
15889
+ return reclaimed;
15612
15890
  }
15613
15891
  /** Remove every checkpoint for a task id. */
15614
15892
  async delete(taskId) {
@@ -16664,6 +16942,25 @@ var Cascade = class _Cascade extends EventEmitter {
16664
16942
  lastInterruptedRun;
16665
16943
  /** Durable resume checkpoints. See core/orchestration/resume-store.ts. */
16666
16944
  resumeStore;
16945
+ /** Claim held by an in-flight resume, settled once the run owns the work. */
16946
+ claimedResumeId;
16947
+ /**
16948
+ * Facts inherited from the checkpoint this run is resuming, so its own
16949
+ * checkpoint is cumulative rather than only covering this attempt.
16950
+ */
16951
+ resumedFrom;
16952
+ /** Whether THIS run persisted a checkpoint of its own. Drives settle-vs-release. */
16953
+ wroteCheckpointThisRun = false;
16954
+ /**
16955
+ * Whether the run reached the end of its normal path.
16956
+ *
16957
+ * NOT the same as `runError == null`: the cancellation and budget handlers
16958
+ * both null `runError` deliberately (an intentional stop is not a telemetry
16959
+ * error) and the breaker path never sets it at all. Treating that as success
16960
+ * meant an immediate cancel — no output, no replacement checkpoint — deleted
16961
+ * the original claim and the finished sections with it.
16962
+ */
16963
+ completedNormally = false;
16667
16964
  /** Section results of the CURRENT run, so an interruption can checkpoint them. */
16668
16965
  currentRunCompleted = [];
16669
16966
  initPromise;
@@ -17181,17 +17478,31 @@ ${input}` }],
17181
17478
  * worse. Nothing here is allowed to throw.
17182
17479
  */
17183
17480
  async checkpointRun(taskId, prompt, partialOutput, reason, detail) {
17184
- if (!this.currentRunCompleted.length && !partialOutput) return;
17481
+ const inherited = this.resumedFrom?.completed ?? [];
17482
+ const merged = /* @__PURE__ */ new Map();
17483
+ for (const node of inherited) merged.set(node.id, node);
17484
+ for (const node of this.currentRunCompleted) {
17485
+ const existing = merged.get(node.id);
17486
+ if (existing?.status === "COMPLETED" && node.status !== "COMPLETED") continue;
17487
+ merged.set(node.id, node);
17488
+ }
17489
+ const completed = [...merged.values()];
17490
+ const canonicalPrompt = this.resumedFrom?.prompt ?? prompt;
17491
+ const carriedPartial = partialOutput || (this.resumedFrom?.partialOutput ?? "");
17492
+ if (!completed.length && !carriedPartial) return false;
17185
17493
  try {
17186
- await this.resumeStore?.save({
17494
+ const saved = await this.resumeStore?.save({
17187
17495
  taskId,
17188
- prompt,
17496
+ prompt: canonicalPrompt,
17189
17497
  reason,
17190
17498
  ...detail ? { detail } : {},
17191
- partialOutput,
17192
- completed: [...this.currentRunCompleted]
17499
+ partialOutput: carriedPartial,
17500
+ completed
17193
17501
  });
17502
+ this.wroteCheckpointThisRun = saved === true;
17503
+ return saved === true;
17194
17504
  } catch {
17505
+ return false;
17195
17506
  }
17196
17507
  }
17197
17508
  /** True when a task can be resumed via /continue (this process or a previous one). */
@@ -17229,11 +17540,55 @@ ${input}` }],
17229
17540
  * checkpoint, so the same finished work is never restored into two runs.
17230
17541
  */
17231
17542
  async prepareDurableResume(opts = {}) {
17232
- const checkpoint = await this.resumeStore?.consume(opts.taskId);
17233
- if (!checkpoint) return null;
17234
- this.lastInterruptedRun = void 0;
17235
- this.raiseBudgetForResume(opts.maxTokens);
17236
- return this.buildResumePrompt(checkpoint.prompt, checkpoint.partialOutput, checkpoint.completed);
17543
+ await this.resumeStore?.reclaimStale();
17544
+ const claimed = await this.resumeStore?.claim(opts.taskId);
17545
+ if (!claimed) return null;
17546
+ try {
17547
+ this.lastInterruptedRun = void 0;
17548
+ this.raiseBudgetForResume(opts.maxTokens);
17549
+ const prompt = this.buildResumePrompt(
17550
+ claimed.checkpoint.prompt,
17551
+ claimed.checkpoint.partialOutput,
17552
+ claimed.checkpoint.completed
17553
+ );
17554
+ this.resumedFrom = {
17555
+ prompt: claimed.checkpoint.prompt,
17556
+ completed: claimed.checkpoint.completed,
17557
+ partialOutput: claimed.checkpoint.partialOutput
17558
+ };
17559
+ this.claimedResumeId = claimed.claimId;
17560
+ return prompt;
17561
+ } catch (err) {
17562
+ await this.resumeStore?.release(claimed.claimId);
17563
+ throw err;
17564
+ }
17565
+ }
17566
+ /**
17567
+ * Discard the claimed checkpoint once the resumed run has taken ownership of
17568
+ * the work (it has produced its own checkpoint, or finished). Called by the
17569
+ * run loop; safe to call when nothing is claimed.
17570
+ */
17571
+ async settleClaimedResume(succeeded) {
17572
+ const claimId = this.claimedResumeId;
17573
+ if (!claimId) return;
17574
+ this.claimedResumeId = void 0;
17575
+ if (succeeded || this.wroteCheckpointThisRun) {
17576
+ await this.resumeStore?.settle(claimId);
17577
+ } else {
17578
+ await this.resumeStore?.release(claimId);
17579
+ }
17580
+ }
17581
+ /**
17582
+ * Hand back a claim taken by prepareDurableResume() when the caller never
17583
+ * gets as far as run(). The REPL prepares the continuation and submits it
17584
+ * separately, so a failure in between would otherwise strand the checkpoint
17585
+ * for the whole lease timeout before reclaimStale() could recover it.
17586
+ */
17587
+ async releaseClaimedResume() {
17588
+ const claimId = this.claimedResumeId;
17589
+ if (!claimId) return;
17590
+ this.claimedResumeId = void 0;
17591
+ await this.resumeStore?.release(claimId);
17237
17592
  }
17238
17593
  /** A resume gets a bigger budget — the previous attempt exhausted the old one. */
17239
17594
  raiseBudgetForResume(maxTokens) {
@@ -17269,7 +17624,12 @@ ${partialOutput}` : ""
17269
17624
  async resumeRun(opts = {}) {
17270
17625
  const prompt = await this.prepareDurableResume(opts) ?? this.prepareResume(opts);
17271
17626
  if (!prompt) return null;
17272
- return this.run({ prompt });
17627
+ try {
17628
+ return await this.run({ prompt });
17629
+ } catch (err) {
17630
+ await this.releaseClaimedResume();
17631
+ throw err;
17632
+ }
17273
17633
  }
17274
17634
  getWorkspacePath() {
17275
17635
  return this.workspacePath;
@@ -17639,6 +17999,9 @@ ${prompt}` : prompt;
17639
17999
  const taskId = randomUUID();
17640
18000
  this.decisionLog = [];
17641
18001
  this.currentRunCompleted = [];
18002
+ this.wroteCheckpointThisRun = false;
18003
+ this.completedNormally = false;
18004
+ if (!this.claimedResumeId) this.resumedFrom = void 0;
17642
18005
  if (options.fastAnswer) {
17643
18006
  return this.runFastAnswer(options, startMs, taskId);
17644
18007
  }
@@ -17917,6 +18280,7 @@ _(incomplete: ${reasons.join("; ")})_` : partial;
17917
18280
  ${finalOutput}` : ""
17918
18281
  ].join("\n").trimEnd();
17919
18282
  }
18283
+ this.completedNormally = trip == null;
17920
18284
  } catch (err) {
17921
18285
  if (err instanceof CascadeCancelledError || err instanceof Error && err.name === "AbortError" || options.signal?.aborted) {
17922
18286
  this.emit("run:cancelled", {
@@ -17958,6 +18322,10 @@ ${finalOutput}` : ""
17958
18322
  escalator.cancelAllPending();
17959
18323
  } catch {
17960
18324
  }
18325
+ try {
18326
+ await this.settleClaimedResume(this.completedNormally);
18327
+ } catch {
18328
+ }
17961
18329
  try {
17962
18330
  this.releasePendingEscalations();
17963
18331
  } catch {
@@ -19586,8 +19954,14 @@ var DashboardSocket = class {
19586
19954
  emitCascadeEvent(ev) {
19587
19955
  this.io.emit("cascade:event", ev);
19588
19956
  }
19589
- emitTierStatus(tierId, role, status, sessionId, action) {
19590
- const payload = { tierId, role, status, action, timestamp: (/* @__PURE__ */ new Date()).toISOString(), sessionId };
19957
+ /**
19958
+ * `extra` exists because this signature is positional: every field the tier
19959
+ * emits that is not named here is silently dropped on this path, while the
19960
+ * other two forwarders spread the whole event through. That asymmetry is how
19961
+ * a field can reach the web dashboard from one code path and not another.
19962
+ */
19963
+ emitTierStatus(tierId, role, status, sessionId, action, extra) {
19964
+ const payload = { tierId, role, status, action, timestamp: (/* @__PURE__ */ new Date()).toISOString(), sessionId, ...extra };
19591
19965
  this.io.emit("tier:status", payload);
19592
19966
  this.io.to(`session:${sessionId}`).emit("tier:status", payload);
19593
19967
  }