cascade-ai 0.68.0 → 0.70.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.cjs CHANGED
@@ -1908,7 +1908,7 @@ function reconcilePrice(model, live) {
1908
1908
  }
1909
1909
 
1910
1910
  // src/constants.ts
1911
- var CASCADE_VERSION = "0.68.0";
1911
+ var CASCADE_VERSION = "0.70.0";
1912
1912
  var CASCADE_CONFIG_DIR = ".cascade";
1913
1913
  var CASCADE_MD_FILE = "CASCADE.md";
1914
1914
  var CASCADE_IGNORE_FILE = ".cascadeignore";
@@ -6070,6 +6070,21 @@ var BaseTier = class extends EventEmitter__default.default {
6070
6070
  * which node (Cockpit node panel / Why panel).
6071
6071
  */
6072
6072
  servingModel;
6073
+ /**
6074
+ * This tier's identity IN THE TASK GRAPH — the section or subtask id the
6075
+ * planner assigned — plus where it sat in the dependency order.
6076
+ *
6077
+ * `id` and `graphNodeId` are deliberately different id spaces, and both are
6078
+ * needed. `id` identifies this tier INSTANCE ("T2_a1b2c3d4", minted per
6079
+ * construction); `graphNodeId` identifies the WORK ("s1"), which is what
6080
+ * dependency edges point at. Without the distinction a graph view cannot be
6081
+ * drawn at all: `dependsOn` lists section ids, while every running tier
6082
+ * reports a generated tier id, so every edge would name a node that never
6083
+ * appears in the stream.
6084
+ */
6085
+ graphNodeId;
6086
+ graphDependsOn;
6087
+ graphWave;
6073
6088
  constructor(role, id, parentId) {
6074
6089
  super();
6075
6090
  this.role = role;
@@ -6081,6 +6096,33 @@ var BaseTier = class extends EventEmitter__default.default {
6081
6096
  setPresenter(on = true) {
6082
6097
  this.isPresenter = on;
6083
6098
  }
6099
+ /**
6100
+ * Record where this tier sits in the compiled task graph.
6101
+ *
6102
+ * Called by whichever tier scheduled this one: the id and edges are known at
6103
+ * construction, the wave only once the scheduler reaches it, so the fields
6104
+ * are set independently rather than all at once.
6105
+ */
6106
+ setGraphPosition(position) {
6107
+ if (position.nodeId !== void 0) this.graphNodeId = position.nodeId;
6108
+ if (position.dependsOn !== void 0) this.graphDependsOn = [...position.dependsOn];
6109
+ if (position.wave !== void 0) this.graphWave = position.wave;
6110
+ }
6111
+ /**
6112
+ * The graph fields, for spreading into a status payload.
6113
+ *
6114
+ * One helper rather than two literals because `tier:status` is emitted from
6115
+ * two different call sites with two different payload shapes; adding a field
6116
+ * to only one of them makes it arrive intermittently, which is worse for a
6117
+ * consumer than never arriving at all.
6118
+ */
6119
+ graphFields() {
6120
+ return {
6121
+ nodeId: this.graphNodeId,
6122
+ dependsOn: this.graphDependsOn ? [...this.graphDependsOn] : void 0,
6123
+ waveId: this.graphWave
6124
+ };
6125
+ }
6084
6126
  getStatus() {
6085
6127
  return this.status;
6086
6128
  }
@@ -6095,7 +6137,8 @@ var BaseTier = class extends EventEmitter__default.default {
6095
6137
  status,
6096
6138
  timestamp,
6097
6139
  output,
6098
- model: this.servingModel
6140
+ model: this.servingModel,
6141
+ ...this.graphFields()
6099
6142
  };
6100
6143
  this.emit("status", event);
6101
6144
  this.emit("tier:status", event);
@@ -6135,7 +6178,8 @@ var BaseTier = class extends EventEmitter__default.default {
6135
6178
  progressPct: update.progressPct,
6136
6179
  timestamp,
6137
6180
  output: update.output,
6138
- model: this.servingModel
6181
+ model: this.servingModel,
6182
+ ...this.graphFields()
6139
6183
  });
6140
6184
  }
6141
6185
  buildMessage(type, to, payload) {
@@ -7037,7 +7081,8 @@ Now execute your subtask using this context where relevant.`
7037
7081
  if (result2.verdict === "passed") passed.push(result2.criterion);
7038
7082
  }
7039
7083
  this.sendStatusUpdate({ progressPct: 70, currentAction: "Self-testing output", status: "IN_PROGRESS" });
7040
- const testResult = await this.selfTest(assignment, output, undecided(acceptanceResults));
7084
+ const pendingAcceptance = undecided(acceptanceResults);
7085
+ const testResult = await this.selfTest(assignment, output, pendingAcceptance);
7041
7086
  checksRun.push(...testResult.checksRun);
7042
7087
  passed.push(...testResult.passed);
7043
7088
  failed.push(...testResult.failed);
@@ -7046,7 +7091,7 @@ Now execute your subtask using this context where relevant.`
7046
7091
  issues.push(`Initial check failed: ${testResult.failed.join(", ")}`);
7047
7092
  const corrected = await this.correctOutput(output, testResult.failed);
7048
7093
  output = corrected;
7049
- const retest = await this.selfTest(assignment, output);
7094
+ const retest = await this.selfTest(assignment, output, pendingAcceptance);
7050
7095
  passed.push(...retest.passed);
7051
7096
  if (retest.failed.length > 0) {
7052
7097
  failed.push(...retest.failed);
@@ -8970,6 +9015,7 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
8970
9015
  for (const a of assignments) {
8971
9016
  const worker = new T3Worker(this.router, this.toolRegistry, this.id);
8972
9017
  if (this.store) worker.setStore(this.store, taskId);
9018
+ worker.setGraphPosition({ nodeId: a.subtaskId, dependsOn: a.dependsOn ?? [] });
8973
9019
  worker.setPeerBus(this.t3PeerBus);
8974
9020
  if (this.permissionEscalator) worker.setPermissionEscalator(this.permissionEscalator);
8975
9021
  if (this.toolCreator) worker.setToolCreator(this.toolCreator);
@@ -9090,6 +9136,7 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
9090
9136
  runnableIds.push(fallbackId);
9091
9137
  }
9092
9138
  wave++;
9139
+ for (const id of runnableIds) workerMap.get(id)?.setGraphPosition({ wave });
9093
9140
  this.log(`Wave ${wave}: running ${runnableIds.length} subtask(s) in parallel`);
9094
9141
  this.sendStatusUpdate({
9095
9142
  progressPct: 20 + Math.min(wave * 10, 60),
@@ -9417,23 +9464,63 @@ var DependencyScheduler = class {
9417
9464
  dependents.get(dependencyId)?.push(node.id);
9418
9465
  }
9419
9466
  }
9420
- const completed = /* @__PURE__ */ new Set();
9467
+ const policy = this.options.onUpstreamFailure ?? "continue";
9468
+ const settled = /* @__PURE__ */ new Set();
9421
9469
  const results = /* @__PURE__ */ new Map();
9470
+ const blocked = /* @__PURE__ */ new Map();
9422
9471
  const waves = [];
9423
9472
  let wave = 0;
9424
- while (completed.size < nodes.length) {
9425
- const ready = nodes.filter((node) => !completed.has(node.id) && (inDegree.get(node.id) ?? 0) === 0);
9473
+ const blockDescendants = async (failedIds) => {
9474
+ const rootsFor = /* @__PURE__ */ new Map();
9475
+ const queue = [];
9476
+ for (const rootId of failedIds) {
9477
+ for (const childId of dependents.get(rootId) ?? []) queue.push({ id: childId, root: rootId });
9478
+ }
9479
+ while (queue.length > 0) {
9480
+ const { id, root } = queue.shift();
9481
+ if (settled.has(id)) continue;
9482
+ const known = rootsFor.get(id);
9483
+ if (known) {
9484
+ if (!known.has(root)) known.add(root);
9485
+ continue;
9486
+ }
9487
+ rootsFor.set(id, /* @__PURE__ */ new Set([root]));
9488
+ for (const childId of dependents.get(id) ?? []) queue.push({ id: childId, root });
9489
+ }
9490
+ for (const node of nodes) {
9491
+ const roots = rootsFor.get(node.id);
9492
+ if (!roots || settled.has(node.id)) continue;
9493
+ const causes = nodes.filter((n) => roots.has(n.id));
9494
+ const blockedBy = causes.map((n) => n.id);
9495
+ const blockedByTitles = causes.map((n) => n.title || n.id);
9496
+ const record = {
9497
+ blockedBy,
9498
+ blockedByTitles,
9499
+ reason: blockedByTitles.length === 1 ? `Required upstream work "${blockedByTitles[0]}" failed` : `Required upstream work ${blockedByTitles.map((t) => `"${t}"`).join(", ")} failed`
9500
+ };
9501
+ blocked.set(node.id, record);
9502
+ settled.add(node.id);
9503
+ await this.hooks.onNodeBlocked?.(node, record);
9504
+ }
9505
+ };
9506
+ while (settled.size < nodes.length) {
9507
+ const ready = nodes.filter((node) => !settled.has(node.id) && (inDegree.get(node.id) ?? 0) === 0);
9426
9508
  if (ready.length === 0) {
9427
9509
  throw new Error("Dependency scheduler received a cyclic or incomplete task graph");
9428
9510
  }
9429
9511
  waves.push(ready.map((node) => node.id));
9430
9512
  await this.hooks.onWaveStart?.(ready, wave);
9513
+ const outcomes = /* @__PURE__ */ new Map();
9431
9514
  const runOne = async (node) => {
9432
9515
  const result = await this.hooks.execute(node);
9433
9516
  results.set(node.id, result);
9434
- completed.add(node.id);
9435
- for (const dependentId of dependents.get(node.id) ?? []) {
9436
- inDegree.set(dependentId, Math.max(0, (inDegree.get(dependentId) ?? 1) - 1));
9517
+ settled.add(node.id);
9518
+ const outcome = this.hooks.classify?.(node, result) ?? "succeeded";
9519
+ outcomes.set(node.id, outcome);
9520
+ if (outcome === "failed" && policy === "block") ; else {
9521
+ for (const dependentId of dependents.get(node.id) ?? []) {
9522
+ inDegree.set(dependentId, Math.max(0, (inDegree.get(dependentId) ?? 1) - 1));
9523
+ }
9437
9524
  }
9438
9525
  await this.hooks.onNodeComplete?.(node, result);
9439
9526
  };
@@ -9442,9 +9529,13 @@ var DependencyScheduler = class {
9442
9529
  } else {
9443
9530
  await Promise.all(ready.map(runOne));
9444
9531
  }
9532
+ const failedThisWave = ready.filter((node) => outcomes.get(node.id) === "failed").map((node) => node.id);
9533
+ if (policy === "block" && failedThisWave.length > 0) {
9534
+ await blockDescendants(failedThisWave);
9535
+ }
9445
9536
  wave++;
9446
9537
  }
9447
- return { results, waves };
9538
+ return { results, waves, blocked };
9448
9539
  }
9449
9540
  };
9450
9541
 
@@ -9545,6 +9636,9 @@ QUALITY RULES:
9545
9636
 
9546
9637
  ${generation}` : ""}`;
9547
9638
  }
9639
+ function producedOutput(result) {
9640
+ return result.status === "COMPLETED" || result.status === "PARTIAL";
9641
+ }
9548
9642
  var T1Administrator = class extends BaseTier {
9549
9643
  router;
9550
9644
  toolRegistry;
@@ -9679,7 +9773,7 @@ Board guidance (must be followed in the plan): ${decision.note}`,
9679
9773
  let allT2Results = await this.dispatchT2Managers(plan.sections);
9680
9774
  let pass = 1;
9681
9775
  const maxReplanPasses = this.config.maxReplanPasses ?? 2;
9682
- const okCount = (rs) => rs.filter((r) => r.status === "COMPLETED" || r.status === "PARTIAL").length;
9776
+ const okCount = (rs) => rs.filter(producedOutput).length;
9683
9777
  while (pass <= maxReplanPasses) {
9684
9778
  const reviewResult = await this.reviewT2Outputs(enrichedPrompt, plan, allT2Results);
9685
9779
  if (reviewResult.approved) {
@@ -9810,7 +9904,7 @@ In 3-5 terse bullets, flag the most important RISKS, GAPS, or over-/under-decomp
9810
9904
  /** Structured, grounded summary of what's already done — used to keep
9811
9905
  * corrective replan passes from re-emitting completed sections. */
9812
9906
  summarizeCompletedSections(results) {
9813
- const done = results.filter((r) => r.status === "COMPLETED" || r.status === "PARTIAL");
9907
+ const done = results.filter(producedOutput);
9814
9908
  if (done.length === 0) return "";
9815
9909
  const lines = done.map((r) => `- "${r.sectionTitle}" [${r.status}]: ${(r.sectionSummary || "(no summary)").slice(0, 300)}`);
9816
9910
  return `ALREADY COMPLETED SECTIONS (do not redo these \u2014 plan only what's still missing):
@@ -9955,6 +10049,7 @@ SPEC RULES \u2014 each subtask is a self-contained spec slice (workers execute f
9955
10049
  if (this.runBreaker) manager.setRunBreaker(this.runBreaker);
9956
10050
  if (this.escalationCallback) manager.setEscalationCallback(this.escalationCallback);
9957
10051
  manager.setHierarchyContext(`You are a T2 Manager for the section "${section.sectionTitle}". You are part of a COMPLEX task overseen by T1 Administrator.`);
10052
+ manager.setGraphPosition({ nodeId: section.sectionId, dependsOn: section.dependsOn ?? [] });
9958
10053
  if (this.store) {
9959
10054
  manager.setStore(this.store);
9960
10055
  }
@@ -10081,6 +10176,13 @@ SPEC RULES \u2014 each subtask is a self-contained spec slice (workers execute f
10081
10176
  const scheduler = new DependencyScheduler(
10082
10177
  compiled.graph,
10083
10178
  {
10179
+ // The wave is the scheduler's own counter, and it is the only place the
10180
+ // parallelism is visible: nodes sharing a wave ran at the same time.
10181
+ // Stamped before the wave executes so the first status event a node
10182
+ // emits already carries it.
10183
+ onWaveStart: (nodes, wave) => {
10184
+ for (const node of nodes) managers[node.ordinal]?.setGraphPosition({ wave });
10185
+ },
10084
10186
  execute: async (node) => {
10085
10187
  const section = node.payload;
10086
10188
  const manager = managers[node.ordinal];
@@ -10115,6 +10217,62 @@ SPEC RULES \u2014 each subtask is a self-contained spec slice (workers execute f
10115
10217
  };
10116
10218
  }
10117
10219
  },
10220
+ // PARTIAL is deliberately not a failure: a degraded-but-real section
10221
+ // can still feed the one after it, and cancelling that work would cost
10222
+ // the user more than letting it try.
10223
+ // BLOCKED cannot reach here today — the scheduler settles blocked nodes
10224
+ // through onNodeBlocked and never executes them, so classify only ever
10225
+ // sees results that ran. It is listed anyway because the failure mode if
10226
+ // that ever changes is silent and expensive: a blocked section would
10227
+ // classify as 'succeeded' and release its own dependents, so work that
10228
+ // should have been skipped would run and be billed.
10229
+ classify: (_node, result) => result.status === "FAILED" || result.status === "BLOCKED" ? "failed" : "succeeded",
10230
+ onNodeBlocked: (node, blockedInfo) => {
10231
+ const section = node.payload;
10232
+ this.log(`\u292B Skipped "${section.sectionTitle}" \u2014 ${blockedInfo.reason}`);
10233
+ resultMap.set(node.id, {
10234
+ sectionId: section.sectionId,
10235
+ sectionTitle: section.sectionTitle,
10236
+ // BLOCKED, not FAILED. This section was never attempted — saying it
10237
+ // failed reports a breakage that did not happen, and hides the one
10238
+ // that did (the upstream section named below).
10239
+ status: "BLOCKED",
10240
+ t3Results: [],
10241
+ sectionSummary: "",
10242
+ issues: [
10243
+ `${blockedInfo.reason}. This section was not attempted, so no tokens were spent on it.`
10244
+ ],
10245
+ blockedBy: { ids: [...blockedInfo.blockedBy], titles: [...blockedInfo.blockedByTitles] }
10246
+ });
10247
+ completedSections++;
10248
+ this.sendStatusUpdate({
10249
+ progressPct: 10 + Math.floor(completedSections / totalSections * 85),
10250
+ currentAction: `Skipped: ${section.sectionTitle} (blocked by an upstream failure)`,
10251
+ status: "IN_PROGRESS"
10252
+ });
10253
+ this.emit("tier:status", {
10254
+ tierId: section.sectionId,
10255
+ // A blocked section has no tier instance — it was never
10256
+ // constructed — so its runtime id and its graph id are the same
10257
+ // thing here. Emitting nodeId anyway keeps every node in the
10258
+ // stream addressable in the graph's id space, which is what
10259
+ // dependsOn edges point at.
10260
+ nodeId: section.sectionId,
10261
+ dependsOn: [...section.dependsOn ?? []],
10262
+ role: "T2",
10263
+ label: section.sectionTitle,
10264
+ status: "BLOCKED",
10265
+ blockedBy: [...blockedInfo.blockedByTitles],
10266
+ output: blockedInfo.reason
10267
+ });
10268
+ this.emit("section:complete", {
10269
+ id: node.id,
10270
+ title: section.sectionTitle,
10271
+ status: "BLOCKED",
10272
+ blockedBy: [...blockedInfo.blockedByTitles],
10273
+ output: ""
10274
+ });
10275
+ },
10118
10276
  onNodeComplete: (node, result) => {
10119
10277
  resultMap.set(node.id, result);
10120
10278
  completedSections++;
@@ -10131,14 +10289,18 @@ SPEC RULES \u2014 each subtask is a self-contained spec slice (workers execute f
10131
10289
  // intra-section T3 wave (t2-manager.ts) — 'sequential' otherwise only
10132
10290
  // serialized T3 workers within a single section while every section's
10133
10291
  // T2Manager (and its T3 workers) still ran in parallel here.
10134
- mode: this.router.getT3ExecutionMode?.() === "sequential" ? "sequential" : "parallel"
10292
+ mode: this.router.getT3ExecutionMode?.() === "sequential" ? "sequential" : "parallel",
10293
+ // A section that declared `dependsOn` said it needs that section's
10294
+ // output. Running it after the dependency failed produces a second
10295
+ // failure, billed, whose cause is the first one — so don't.
10296
+ onUpstreamFailure: "block"
10135
10297
  }
10136
10298
  );
10137
10299
  await scheduler.run();
10138
10300
  return sections.map((s) => resultMap.get(s.sectionId)).filter(Boolean);
10139
10301
  }
10140
10302
  async compileFinalOutput(originalPrompt, plan, t2Results) {
10141
- const completedSections = t2Results.filter((r) => r.status !== "FAILED");
10303
+ const completedSections = t2Results.filter(producedOutput);
10142
10304
  if (!completedSections.length) {
10143
10305
  const allIssues = [
10144
10306
  ...t2Results.flatMap((r) => r.issues ?? []),
@@ -10158,7 +10320,6 @@ ${r.sectionSummary}
10158
10320
  Outputs:
10159
10321
  ${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");
10160
10322
  const openIssues = t2Results.flatMap((r) => r.issues).filter(Boolean);
10161
- t2Results.filter((r) => r.status === "FAILED" || r.status === "PARTIAL");
10162
10323
  const compilePrompt = `Compile a final, coherent response to the user's original request.
10163
10324
 
10164
10325
  Original request: ${originalPrompt}
@@ -10650,6 +10811,7 @@ Model: ${asset.modelId} \u2014 ${r.selected.reason}`;
10650
10811
  };
10651
10812
  var DEFAULT_VIDEO_SECONDS = 5;
10652
10813
  var MAX_VIDEO_SECONDS = 8;
10814
+ var MIN_VIDEO_SECONDS = 4;
10653
10815
  var GenerateVideoTool = class extends BaseTool {
10654
10816
  constructor(deps) {
10655
10817
  super();
@@ -10666,7 +10828,7 @@ var GenerateVideoTool = class extends BaseTool {
10666
10828
  type: "object",
10667
10829
  properties: {
10668
10830
  prompt: { type: "string", description: "What the video should show: subject, action, camera movement, style." },
10669
- seconds: { type: "number", description: `Clip length, 1-${MAX_VIDEO_SECONDS} seconds (default ${DEFAULT_VIDEO_SECONDS}). Billed per second \u2014 keep it short.` },
10831
+ seconds: { type: "number", description: `Clip length, ${MIN_VIDEO_SECONDS}-${MAX_VIDEO_SECONDS} seconds (default ${DEFAULT_VIDEO_SECONDS}). Billed per second \u2014 keep it short.` },
10670
10832
  aspectRatio: { type: "string", description: 'Frame shape: "16:9" (default) or "9:16" for vertical.' },
10671
10833
  model: { type: "string", description: "Optional: force a specific video model." }
10672
10834
  },
@@ -10678,7 +10840,7 @@ var GenerateVideoTool = class extends BaseTool {
10678
10840
  const r = resolve(this.deps, "video", input["model"] ? String(input["model"]) : void 0);
10679
10841
  if (!r.ok) return r.error;
10680
10842
  const asked = typeof input["seconds"] === "number" ? input["seconds"] : DEFAULT_VIDEO_SECONDS;
10681
- const seconds = Math.max(1, Math.min(MAX_VIDEO_SECONDS, Math.round(asked)));
10843
+ const seconds = Math.max(MIN_VIDEO_SECONDS, Math.min(MAX_VIDEO_SECONDS, Math.round(asked)));
10682
10844
  const asset = await generateVideo(
10683
10845
  r.selected.capability,
10684
10846
  r.cfg,
@@ -10695,7 +10857,7 @@ var GenerateVideoTool = class extends BaseTool {
10695
10857
  const location = await this.deps.sink(asset);
10696
10858
  const unit = r.selected.cost;
10697
10859
  const estimate = unit ? ` Estimated cost: $${(unit.amount * seconds).toFixed(2)} (${seconds}s x ${unit.label}).` : "";
10698
- const clamped = seconds !== Math.round(asked) ? ` Duration was clamped to ${seconds}s (max ${MAX_VIDEO_SECONDS}).` : "";
10860
+ const clamped = seconds !== Math.round(asked) ? ` Duration was clamped to ${seconds}s (allowed ${MIN_VIDEO_SECONDS}-${MAX_VIDEO_SECONDS}).` : "";
10699
10861
  return `Video generated and saved to ${location}.
10700
10862
  Model: ${asset.modelId}.${estimate}${clamped}`;
10701
10863
  }
@@ -14449,7 +14611,24 @@ var TaskAnalyzer = class {
14449
14611
  feedback;
14450
14612
  bias;
14451
14613
  lastProfile = null;
14452
- lastSelectedModels = /* @__PURE__ */ new Map();
14614
+ /** Models chosen by the run currently in flight. Cleared when it completes. */
14615
+ currentRunSelections = /* @__PURE__ */ new Map();
14616
+ /**
14617
+ * Immutable snapshot of the last COMPLETED run: its selections AND the task
14618
+ * type they were chosen for.
14619
+ *
14620
+ * The task type has to travel WITH the selections. `lastProfile` is overwritten
14621
+ * by the next analyze(), so if run B started before the user rated run A, A's
14622
+ * models were credited under B's task type — teaching the router that a coding
14623
+ * model is good at creative writing, from a rating that never said so.
14624
+ *
14625
+ * Explicit ratings arrive after the run finishes — rateLastRun() is by nature
14626
+ * a reaction to a finished answer — but the run finalizer calls
14627
+ * recordRunOutcome() first, and that used to clear the only map there was. So
14628
+ * every explicit rating iterated an empty map, recorded nothing, and returned
14629
+ * false: the 3x-weighted user signal never reached the tracker at all.
14630
+ */
14631
+ lastCompletedRun;
14453
14632
  constructor(tracker, bias = "balanced") {
14454
14633
  this.tracker = tracker;
14455
14634
  this.bias = bias;
@@ -14496,11 +14675,15 @@ var TaskAnalyzer = class {
14496
14675
  */
14497
14676
  async selectModel(prompt, tier, selector, opts) {
14498
14677
  const profile = await this.analyze(prompt);
14678
+ const recordSelection = (model) => {
14679
+ if (model) this.currentRunSelections.set(tier, model);
14680
+ return model;
14681
+ };
14499
14682
  if (profile.requiresVision) {
14500
- return selector.selectVisionModel();
14683
+ return recordSelection(selector.selectVisionModel());
14501
14684
  }
14502
14685
  let candidates = selector.getCandidatesForTier(tier);
14503
- if (candidates.length === 0) return selector.selectForTier(tier);
14686
+ if (candidates.length === 0) return recordSelection(selector.selectForTier(tier));
14504
14687
  if (opts?.requiresToolUse) {
14505
14688
  const toolCapable = candidates.filter((m) => m.supportsToolUse !== false);
14506
14689
  if (toolCapable.length > 0) candidates = toolCapable;
@@ -14510,9 +14693,7 @@ var TaskAnalyzer = class {
14510
14693
  score: this.scoreModel(m, profile)
14511
14694
  }));
14512
14695
  scored.sort((a, b) => b.score - a.score);
14513
- const best = scored[0]?.model ?? selector.selectForTier(tier);
14514
- if (best) this.lastSelectedModels.set(tier, best);
14515
- return best;
14696
+ return recordSelection(scored[0]?.model ?? selector.selectForTier(tier));
14516
14697
  }
14517
14698
  /**
14518
14699
  * Record the outcome of a completed run across all tiers that were selected
@@ -14521,25 +14702,38 @@ var TaskAnalyzer = class {
14521
14702
  recordRunOutcome(outcome, costByTier, contextTokens = 0) {
14522
14703
  if (!this.tracker || !this.lastProfile) return;
14523
14704
  const taskType = this.lastProfile.type;
14524
- for (const [tier, model] of this.lastSelectedModels) {
14705
+ for (const [tier, model] of this.currentRunSelections) {
14525
14706
  const cost = costByTier[tier] ?? 0;
14526
14707
  this.tracker.record(model.id, taskType, outcome, 0, cost, contextTokens);
14527
14708
  }
14528
- this.lastSelectedModels.clear();
14709
+ this.lastCompletedRun = { selections: new Map(this.currentRunSelections), taskType };
14710
+ this.currentRunSelections.clear();
14529
14711
  void this.tracker.save();
14530
14712
  }
14531
14713
  /**
14532
- * Record an explicit user rating (good/bad) for the last run's selected models.
14714
+ * Record an explicit user rating (good/bad) for the last COMPLETED run's models.
14533
14715
  * Explicit ratings carry 3× the weight of auto-detected outcomes.
14534
- * Does NOT clear lastSelectedModels — the auto record already did that.
14716
+ *
14717
+ * Reads the completed-run snapshot, not the in-flight map: by the time a user
14718
+ * can rate an answer, the finalizer has already run and the in-flight map is
14719
+ * empty.
14720
+ *
14721
+ * The snapshot is CONSUMED. recordExplicit() weights a rating 3x by recording
14722
+ * three samples, so a double submit would inject six — a stutter on the
14723
+ * button would count as two opinions. Consuming makes a second call a no-op
14724
+ * that reports false, which is what "rate this run" should mean.
14535
14725
  */
14536
14726
  recordExplicitRating(rating) {
14537
- if (!this.tracker || !this.lastProfile) return false;
14538
- const taskType = this.lastProfile.type;
14539
- for (const [, model] of this.lastSelectedModels) {
14540
- this.tracker.recordExplicit(model.id, taskType, rating, 0);
14541
- }
14542
- return this.lastSelectedModels.size > 0;
14727
+ const snapshot = this.lastCompletedRun;
14728
+ if (!this.tracker || !snapshot || snapshot.selections.size === 0) return false;
14729
+ const ratedModels = /* @__PURE__ */ new Set();
14730
+ for (const [, model] of snapshot.selections) {
14731
+ if (ratedModels.has(model.id)) continue;
14732
+ ratedModels.add(model.id);
14733
+ this.tracker.recordExplicit(model.id, snapshot.taskType, rating, 0);
14734
+ }
14735
+ this.lastCompletedRun = void 0;
14736
+ return true;
14543
14737
  }
14544
14738
  scoreModel(model, profile) {
14545
14739
  const perf = this.tracker?.performanceScore(model.id, profile.type) ?? 0.5;
@@ -15577,6 +15771,8 @@ var RESUME_SCHEMA_VERSION = 1;
15577
15771
  var DEFAULT_MAX_CHECKPOINTS = 5;
15578
15772
  var DEFAULT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1e3;
15579
15773
  var FILE_PATTERN = /^(\d+)-(.+)\.json$/;
15774
+ var CLAIM_SUFFIX = ".claimed";
15775
+ var DEFAULT_CLAIM_TIMEOUT_MS = 10 * 60 * 1e3;
15580
15776
  function safeId(taskId) {
15581
15777
  const cleaned = taskId.replace(/[^A-Za-z0-9._-]/g, "_").replace(/\.{2,}/g, "_");
15582
15778
  return cleaned.slice(0, 100) || "run";
@@ -15591,16 +15787,24 @@ var ResumeStore = class {
15591
15787
  maxCheckpoints;
15592
15788
  maxAgeMs;
15593
15789
  now;
15790
+ claimTimeoutMs;
15594
15791
  constructor(options) {
15595
15792
  this.dir = options.dir;
15596
15793
  this.maxCheckpoints = options.maxCheckpoints ?? DEFAULT_MAX_CHECKPOINTS;
15597
15794
  this.maxAgeMs = options.maxAgeMs ?? DEFAULT_MAX_AGE_MS;
15598
15795
  this.now = options.now ?? Date.now;
15796
+ this.claimTimeoutMs = options.claimTimeoutMs ?? DEFAULT_CLAIM_TIMEOUT_MS;
15599
15797
  }
15600
15798
  /**
15601
- * Persist a checkpoint and prune. Never throws: failing to save a resume file
15602
- * must not turn a recoverable stop into a crash, which would be a strictly
15603
- * worse outcome than the one this store exists to improve.
15799
+ * Persist a checkpoint and prune.
15800
+ *
15801
+ * Never throws — failing to save a resume file must not turn a recoverable
15802
+ * stop into a crash — but DOES report whether the atomic rename landed.
15803
+ * Callers decide whether an original claim is safe to discard based on that
15804
+ * answer, so "I swallowed the error" and "the checkpoint is on disk" must not
15805
+ * look alike to them.
15806
+ *
15807
+ * @returns true only when the checkpoint is durably on disk.
15604
15808
  */
15605
15809
  async save(checkpoint) {
15606
15810
  try {
@@ -15616,7 +15820,9 @@ var ResumeStore = class {
15616
15820
  await fs25.promises.writeFile(tempPath, JSON.stringify(record, null, 2), "utf-8");
15617
15821
  await fs25.promises.rename(tempPath, finalPath);
15618
15822
  await this.prune();
15823
+ return true;
15619
15824
  } catch {
15825
+ return false;
15620
15826
  }
15621
15827
  }
15622
15828
  /** Valid, unexpired checkpoints, newest first. */
@@ -15646,15 +15852,87 @@ var ResumeStore = class {
15646
15852
  return (await this.list())[0] ?? null;
15647
15853
  }
15648
15854
  /**
15649
- * Load a checkpoint and delete it in the same step. Resuming twice from one
15650
- * checkpoint would re-run restored work, so consumption is part of reading.
15855
+ * Claim a checkpoint for a resume attempt.
15856
+ *
15857
+ * This used to DELETE the file and return it, which opened a window with no
15858
+ * recovery record at all: between preparing the continuation and the resumed
15859
+ * run writing its own checkpoint, a crash lost the only copy — and a crash is
15860
+ * precisely what this store exists to survive. Deleting on read optimised for
15861
+ * "never resume twice" at the cost of "never lose the work", which is the
15862
+ * wrong trade for a recovery mechanism.
15863
+ *
15864
+ * Instead the file is RENAMED to `.claimed`, which still hides it from
15865
+ * `list()` (so a concurrent resume cannot take the same work) but keeps it on
15866
+ * disk. The caller then either `release`s it — restoring it for another
15867
+ * attempt — or `settle`s it once the resumed run is safely under way. A claim
15868
+ * abandoned by a dead process is reclaimed after `claimTimeoutMs`.
15651
15869
  */
15652
- async consume(taskId) {
15870
+ async claim(taskId) {
15653
15871
  const all = await this.list();
15654
15872
  const target = taskId ? all.find((c) => c.taskId === taskId) : all[0];
15655
15873
  if (!target) return null;
15656
- await this.delete(target.taskId);
15657
- return target;
15874
+ try {
15875
+ const entries = await fs25.promises.readdir(this.dir);
15876
+ const wanted = safeId(target.taskId);
15877
+ const source = entries.find((name) => FILE_PATTERN.exec(name)?.[2] === wanted);
15878
+ if (!source) return null;
15879
+ const claimId = `${this.now()}-${safeId(target.taskId)}`;
15880
+ await fs25.promises.rename(path26__default.default.join(this.dir, source), path26__default.default.join(this.dir, `${claimId}${CLAIM_SUFFIX}`));
15881
+ return { checkpoint: target, claimId };
15882
+ } catch {
15883
+ return null;
15884
+ }
15885
+ }
15886
+ /** Give a claim back, so the work stays recoverable after a failed start. */
15887
+ async release(claimId) {
15888
+ try {
15889
+ await fs25.promises.rename(
15890
+ path26__default.default.join(this.dir, `${claimId}${CLAIM_SUFFIX}`),
15891
+ path26__default.default.join(this.dir, `${claimId}.json`)
15892
+ );
15893
+ } catch {
15894
+ }
15895
+ }
15896
+ /** Discard a claim for good — the resumed run has taken ownership. */
15897
+ async settle(claimId) {
15898
+ try {
15899
+ await fs25.promises.rm(path26__default.default.join(this.dir, `${claimId}${CLAIM_SUFFIX}`), { force: true });
15900
+ } catch {
15901
+ }
15902
+ }
15903
+ /**
15904
+ * Claim-and-discard in one step. Kept for callers that genuinely want the old
15905
+ * semantics; prefer claim/settle so a crash mid-resume stays recoverable.
15906
+ */
15907
+ async consume(taskId) {
15908
+ const claimed = await this.claim(taskId);
15909
+ if (!claimed) return null;
15910
+ await this.settle(claimed.claimId);
15911
+ return claimed.checkpoint;
15912
+ }
15913
+ /**
15914
+ * Return claims abandoned by a process that died mid-resume. Without this a
15915
+ * crash between claim and settle would strand the checkpoint invisibly —
15916
+ * trading one loss window for another.
15917
+ */
15918
+ async reclaimStale() {
15919
+ let reclaimed = 0;
15920
+ try {
15921
+ const entries = await fs25.promises.readdir(this.dir);
15922
+ const cutoff = this.now() - this.claimTimeoutMs;
15923
+ for (const name of entries) {
15924
+ if (!name.endsWith(CLAIM_SUFFIX)) continue;
15925
+ const stamp2 = Number(/^(\d+)-/.exec(name)?.[1]);
15926
+ if (!Number.isFinite(stamp2) || stamp2 >= cutoff) continue;
15927
+ await fs25.promises.rename(
15928
+ path26__default.default.join(this.dir, name),
15929
+ path26__default.default.join(this.dir, `${name.slice(0, -CLAIM_SUFFIX.length)}.json`)
15930
+ );
15931
+ reclaimed++;
15932
+ }
15933
+ } catch {
15934
+ }
15935
+ return reclaimed;
15658
15936
  }
15659
15937
  /** Remove every checkpoint for a task id. */
15660
15938
  async delete(taskId) {
@@ -16710,6 +16988,25 @@ var Cascade = class _Cascade extends EventEmitter__default.default {
16710
16988
  lastInterruptedRun;
16711
16989
  /** Durable resume checkpoints. See core/orchestration/resume-store.ts. */
16712
16990
  resumeStore;
16991
+ /** Claim held by an in-flight resume, settled once the run owns the work. */
16992
+ claimedResumeId;
16993
+ /**
16994
+ * Facts inherited from the checkpoint this run is resuming, so its own
16995
+ * checkpoint is cumulative rather than only covering this attempt.
16996
+ */
16997
+ resumedFrom;
16998
+ /** Whether THIS run persisted a checkpoint of its own. Drives settle-vs-release. */
16999
+ wroteCheckpointThisRun = false;
17000
+ /**
17001
+ * Whether the run reached the end of its normal path.
17002
+ *
17003
+ * NOT the same as `runError == null`: the cancellation and budget handlers
17004
+ * both null `runError` deliberately (an intentional stop is not a telemetry
17005
+ * error) and the breaker path never sets it at all. Treating that as success
17006
+ * meant an immediate cancel — no output, no replacement checkpoint — deleted
17007
+ * the original claim and the finished sections with it.
17008
+ */
17009
+ completedNormally = false;
16713
17010
  /** Section results of the CURRENT run, so an interruption can checkpoint them. */
16714
17011
  currentRunCompleted = [];
16715
17012
  initPromise;
@@ -17227,17 +17524,31 @@ ${input}` }],
17227
17524
  * worse. Nothing here is allowed to throw.
17228
17525
  */
17229
17526
  async checkpointRun(taskId, prompt, partialOutput, reason, detail) {
17230
- if (!this.currentRunCompleted.length && !partialOutput) return;
17527
+ const inherited = this.resumedFrom?.completed ?? [];
17528
+ const merged = /* @__PURE__ */ new Map();
17529
+ for (const node of inherited) merged.set(node.id, node);
17530
+ for (const node of this.currentRunCompleted) {
17531
+ const existing = merged.get(node.id);
17532
+ if (existing?.status === "COMPLETED" && node.status !== "COMPLETED") continue;
17533
+ merged.set(node.id, node);
17534
+ }
17535
+ const completed = [...merged.values()];
17536
+ const canonicalPrompt = this.resumedFrom?.prompt ?? prompt;
17537
+ const carriedPartial = partialOutput || (this.resumedFrom?.partialOutput ?? "");
17538
+ if (!completed.length && !carriedPartial) return false;
17231
17539
  try {
17232
- await this.resumeStore?.save({
17540
+ const saved = await this.resumeStore?.save({
17233
17541
  taskId,
17234
- prompt,
17542
+ prompt: canonicalPrompt,
17235
17543
  reason,
17236
17544
  ...detail ? { detail } : {},
17237
- partialOutput,
17238
- completed: [...this.currentRunCompleted]
17545
+ partialOutput: carriedPartial,
17546
+ completed
17239
17547
  });
17548
+ this.wroteCheckpointThisRun = saved === true;
17549
+ return saved === true;
17240
17550
  } catch {
17551
+ return false;
17241
17552
  }
17242
17553
  }
17243
17554
  /** True when a task can be resumed via /continue (this process or a previous one). */
@@ -17275,11 +17586,55 @@ ${input}` }],
17275
17586
  * checkpoint, so the same finished work is never restored into two runs.
17276
17587
  */
17277
17588
  async prepareDurableResume(opts = {}) {
17278
- const checkpoint = await this.resumeStore?.consume(opts.taskId);
17279
- if (!checkpoint) return null;
17280
- this.lastInterruptedRun = void 0;
17281
- this.raiseBudgetForResume(opts.maxTokens);
17282
- return this.buildResumePrompt(checkpoint.prompt, checkpoint.partialOutput, checkpoint.completed);
17589
+ await this.resumeStore?.reclaimStale();
17590
+ const claimed = await this.resumeStore?.claim(opts.taskId);
17591
+ if (!claimed) return null;
17592
+ try {
17593
+ this.lastInterruptedRun = void 0;
17594
+ this.raiseBudgetForResume(opts.maxTokens);
17595
+ const prompt = this.buildResumePrompt(
17596
+ claimed.checkpoint.prompt,
17597
+ claimed.checkpoint.partialOutput,
17598
+ claimed.checkpoint.completed
17599
+ );
17600
+ this.resumedFrom = {
17601
+ prompt: claimed.checkpoint.prompt,
17602
+ completed: claimed.checkpoint.completed,
17603
+ partialOutput: claimed.checkpoint.partialOutput
17604
+ };
17605
+ this.claimedResumeId = claimed.claimId;
17606
+ return prompt;
17607
+ } catch (err) {
17608
+ await this.resumeStore?.release(claimed.claimId);
17609
+ throw err;
17610
+ }
17611
+ }
17612
+ /**
17613
+ * Discard the claimed checkpoint once the resumed run has taken ownership of
17614
+ * the work (it has produced its own checkpoint, or finished). Called by the
17615
+ * run loop; safe to call when nothing is claimed.
17616
+ */
17617
+ async settleClaimedResume(succeeded) {
17618
+ const claimId = this.claimedResumeId;
17619
+ if (!claimId) return;
17620
+ this.claimedResumeId = void 0;
17621
+ if (succeeded || this.wroteCheckpointThisRun) {
17622
+ await this.resumeStore?.settle(claimId);
17623
+ } else {
17624
+ await this.resumeStore?.release(claimId);
17625
+ }
17626
+ }
17627
+ /**
17628
+ * Hand back a claim taken by prepareDurableResume() when the caller never
17629
+ * gets as far as run(). The REPL prepares the continuation and submits it
17630
+ * separately, so a failure in between would otherwise strand the checkpoint
17631
+ * for the whole lease timeout before reclaimStale() could recover it.
17632
+ */
17633
+ async releaseClaimedResume() {
17634
+ const claimId = this.claimedResumeId;
17635
+ if (!claimId) return;
17636
+ this.claimedResumeId = void 0;
17637
+ await this.resumeStore?.release(claimId);
17283
17638
  }
17284
17639
  /** A resume gets a bigger budget — the previous attempt exhausted the old one. */
17285
17640
  raiseBudgetForResume(maxTokens) {
@@ -17315,7 +17670,12 @@ ${partialOutput}` : ""
17315
17670
  async resumeRun(opts = {}) {
17316
17671
  const prompt = await this.prepareDurableResume(opts) ?? this.prepareResume(opts);
17317
17672
  if (!prompt) return null;
17318
- return this.run({ prompt });
17673
+ try {
17674
+ return await this.run({ prompt });
17675
+ } catch (err) {
17676
+ await this.releaseClaimedResume();
17677
+ throw err;
17678
+ }
17319
17679
  }
17320
17680
  getWorkspacePath() {
17321
17681
  return this.workspacePath;
@@ -17685,6 +18045,9 @@ ${prompt}` : prompt;
17685
18045
  const taskId = crypto4.randomUUID();
17686
18046
  this.decisionLog = [];
17687
18047
  this.currentRunCompleted = [];
18048
+ this.wroteCheckpointThisRun = false;
18049
+ this.completedNormally = false;
18050
+ if (!this.claimedResumeId) this.resumedFrom = void 0;
17688
18051
  if (options.fastAnswer) {
17689
18052
  return this.runFastAnswer(options, startMs, taskId);
17690
18053
  }
@@ -17963,6 +18326,7 @@ _(incomplete: ${reasons.join("; ")})_` : partial;
17963
18326
  ${finalOutput}` : ""
17964
18327
  ].join("\n").trimEnd();
17965
18328
  }
18329
+ this.completedNormally = trip == null;
17966
18330
  } catch (err) {
17967
18331
  if (err instanceof CascadeCancelledError || err instanceof Error && err.name === "AbortError" || options.signal?.aborted) {
17968
18332
  this.emit("run:cancelled", {
@@ -18004,6 +18368,10 @@ ${finalOutput}` : ""
18004
18368
  escalator.cancelAllPending();
18005
18369
  } catch {
18006
18370
  }
18371
+ try {
18372
+ await this.settleClaimedResume(this.completedNormally);
18373
+ } catch {
18374
+ }
18007
18375
  try {
18008
18376
  this.releasePendingEscalations();
18009
18377
  } catch {
@@ -19632,8 +20000,14 @@ var DashboardSocket = class {
19632
20000
  emitCascadeEvent(ev) {
19633
20001
  this.io.emit("cascade:event", ev);
19634
20002
  }
19635
- emitTierStatus(tierId, role, status, sessionId, action) {
19636
- const payload = { tierId, role, status, action, timestamp: (/* @__PURE__ */ new Date()).toISOString(), sessionId };
20003
+ /**
20004
+ * `extra` exists because this signature is positional: every field the tier
20005
+ * emits that is not named here is silently dropped on this path, while the
20006
+ * other two forwarders spread the whole event through. That asymmetry is how
20007
+ * a field can reach the web dashboard from one code path and not another.
20008
+ */
20009
+ emitTierStatus(tierId, role, status, sessionId, action, extra) {
20010
+ const payload = { tierId, role, status, action, timestamp: (/* @__PURE__ */ new Date()).toISOString(), sessionId, ...extra };
19637
20011
  this.io.emit("tier:status", payload);
19638
20012
  this.io.to(`session:${sessionId}`).emit("tier:status", payload);
19639
20013
  }