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.
@@ -291781,7 +291781,7 @@ function reconcilePrice(model, live) {
291781
291781
  }
291782
291782
 
291783
291783
  // src/constants.ts
291784
- var CASCADE_VERSION = "0.68.0";
291784
+ var CASCADE_VERSION = "0.69.0";
291785
291785
  var CASCADE_CONFIG_DIR = ".cascade";
291786
291786
  var CASCADE_MD_FILE = "CASCADE.md";
291787
291787
  var CASCADE_IGNORE_FILE = ".cascadeignore";
@@ -321846,6 +321846,21 @@ var BaseTier = class extends EventEmitter__default.default {
321846
321846
  * which node (Cockpit node panel / Why panel).
321847
321847
  */
321848
321848
  servingModel;
321849
+ /**
321850
+ * This tier's identity IN THE TASK GRAPH — the section or subtask id the
321851
+ * planner assigned — plus where it sat in the dependency order.
321852
+ *
321853
+ * `id` and `graphNodeId` are deliberately different id spaces, and both are
321854
+ * needed. `id` identifies this tier INSTANCE ("T2_a1b2c3d4", minted per
321855
+ * construction); `graphNodeId` identifies the WORK ("s1"), which is what
321856
+ * dependency edges point at. Without the distinction a graph view cannot be
321857
+ * drawn at all: `dependsOn` lists section ids, while every running tier
321858
+ * reports a generated tier id, so every edge would name a node that never
321859
+ * appears in the stream.
321860
+ */
321861
+ graphNodeId;
321862
+ graphDependsOn;
321863
+ graphWave;
321849
321864
  constructor(role, id, parentId) {
321850
321865
  super();
321851
321866
  this.role = role;
@@ -321857,6 +321872,33 @@ var BaseTier = class extends EventEmitter__default.default {
321857
321872
  setPresenter(on = true) {
321858
321873
  this.isPresenter = on;
321859
321874
  }
321875
+ /**
321876
+ * Record where this tier sits in the compiled task graph.
321877
+ *
321878
+ * Called by whichever tier scheduled this one: the id and edges are known at
321879
+ * construction, the wave only once the scheduler reaches it, so the fields
321880
+ * are set independently rather than all at once.
321881
+ */
321882
+ setGraphPosition(position) {
321883
+ if (position.nodeId !== void 0) this.graphNodeId = position.nodeId;
321884
+ if (position.dependsOn !== void 0) this.graphDependsOn = [...position.dependsOn];
321885
+ if (position.wave !== void 0) this.graphWave = position.wave;
321886
+ }
321887
+ /**
321888
+ * The graph fields, for spreading into a status payload.
321889
+ *
321890
+ * One helper rather than two literals because `tier:status` is emitted from
321891
+ * two different call sites with two different payload shapes; adding a field
321892
+ * to only one of them makes it arrive intermittently, which is worse for a
321893
+ * consumer than never arriving at all.
321894
+ */
321895
+ graphFields() {
321896
+ return {
321897
+ nodeId: this.graphNodeId,
321898
+ dependsOn: this.graphDependsOn ? [...this.graphDependsOn] : void 0,
321899
+ waveId: this.graphWave
321900
+ };
321901
+ }
321860
321902
  getStatus() {
321861
321903
  return this.status;
321862
321904
  }
@@ -321871,7 +321913,8 @@ var BaseTier = class extends EventEmitter__default.default {
321871
321913
  status,
321872
321914
  timestamp,
321873
321915
  output,
321874
- model: this.servingModel
321916
+ model: this.servingModel,
321917
+ ...this.graphFields()
321875
321918
  };
321876
321919
  this.emit("status", event);
321877
321920
  this.emit("tier:status", event);
@@ -321911,7 +321954,8 @@ var BaseTier = class extends EventEmitter__default.default {
321911
321954
  progressPct: update.progressPct,
321912
321955
  timestamp,
321913
321956
  output: update.output,
321914
- model: this.servingModel
321957
+ model: this.servingModel,
321958
+ ...this.graphFields()
321915
321959
  });
321916
321960
  }
321917
321961
  buildMessage(type, to, payload) {
@@ -322813,7 +322857,8 @@ Now execute your subtask using this context where relevant.`
322813
322857
  if (result2.verdict === "passed") passed.push(result2.criterion);
322814
322858
  }
322815
322859
  this.sendStatusUpdate({ progressPct: 70, currentAction: "Self-testing output", status: "IN_PROGRESS" });
322816
- const testResult = await this.selfTest(assignment, output, undecided(acceptanceResults));
322860
+ const pendingAcceptance = undecided(acceptanceResults);
322861
+ const testResult = await this.selfTest(assignment, output, pendingAcceptance);
322817
322862
  checksRun.push(...testResult.checksRun);
322818
322863
  passed.push(...testResult.passed);
322819
322864
  failed.push(...testResult.failed);
@@ -322822,7 +322867,7 @@ Now execute your subtask using this context where relevant.`
322822
322867
  issues.push(`Initial check failed: ${testResult.failed.join(", ")}`);
322823
322868
  const corrected = await this.correctOutput(output, testResult.failed);
322824
322869
  output = corrected;
322825
- const retest = await this.selfTest(assignment, output);
322870
+ const retest = await this.selfTest(assignment, output, pendingAcceptance);
322826
322871
  passed.push(...retest.passed);
322827
322872
  if (retest.failed.length > 0) {
322828
322873
  failed.push(...retest.failed);
@@ -324746,6 +324791,7 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
324746
324791
  for (const a2 of assignments) {
324747
324792
  const worker = new T3Worker(this.router, this.toolRegistry, this.id);
324748
324793
  if (this.store) worker.setStore(this.store, taskId);
324794
+ worker.setGraphPosition({ nodeId: a2.subtaskId, dependsOn: a2.dependsOn ?? [] });
324749
324795
  worker.setPeerBus(this.t3PeerBus);
324750
324796
  if (this.permissionEscalator) worker.setPermissionEscalator(this.permissionEscalator);
324751
324797
  if (this.toolCreator) worker.setToolCreator(this.toolCreator);
@@ -324866,6 +324912,7 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
324866
324912
  runnableIds.push(fallbackId);
324867
324913
  }
324868
324914
  wave++;
324915
+ for (const id of runnableIds) workerMap.get(id)?.setGraphPosition({ wave });
324869
324916
  this.log(`Wave ${wave}: running ${runnableIds.length} subtask(s) in parallel`);
324870
324917
  this.sendStatusUpdate({
324871
324918
  progressPct: 20 + Math.min(wave * 10, 60),
@@ -325193,23 +325240,63 @@ var DependencyScheduler = class {
325193
325240
  dependents.get(dependencyId)?.push(node.id);
325194
325241
  }
325195
325242
  }
325196
- const completed = /* @__PURE__ */ new Set();
325243
+ const policy = this.options.onUpstreamFailure ?? "continue";
325244
+ const settled = /* @__PURE__ */ new Set();
325197
325245
  const results = /* @__PURE__ */ new Map();
325246
+ const blocked = /* @__PURE__ */ new Map();
325198
325247
  const waves = [];
325199
325248
  let wave = 0;
325200
- while (completed.size < nodes.length) {
325201
- const ready = nodes.filter((node) => !completed.has(node.id) && (inDegree.get(node.id) ?? 0) === 0);
325249
+ const blockDescendants = async (failedIds) => {
325250
+ const rootsFor = /* @__PURE__ */ new Map();
325251
+ const queue2 = [];
325252
+ for (const rootId of failedIds) {
325253
+ for (const childId of dependents.get(rootId) ?? []) queue2.push({ id: childId, root: rootId });
325254
+ }
325255
+ while (queue2.length > 0) {
325256
+ const { id, root } = queue2.shift();
325257
+ if (settled.has(id)) continue;
325258
+ const known = rootsFor.get(id);
325259
+ if (known) {
325260
+ if (!known.has(root)) known.add(root);
325261
+ continue;
325262
+ }
325263
+ rootsFor.set(id, /* @__PURE__ */ new Set([root]));
325264
+ for (const childId of dependents.get(id) ?? []) queue2.push({ id: childId, root });
325265
+ }
325266
+ for (const node of nodes) {
325267
+ const roots = rootsFor.get(node.id);
325268
+ if (!roots || settled.has(node.id)) continue;
325269
+ const causes = nodes.filter((n8) => roots.has(n8.id));
325270
+ const blockedBy = causes.map((n8) => n8.id);
325271
+ const blockedByTitles = causes.map((n8) => n8.title || n8.id);
325272
+ const record2 = {
325273
+ blockedBy,
325274
+ blockedByTitles,
325275
+ reason: blockedByTitles.length === 1 ? `Required upstream work "${blockedByTitles[0]}" failed` : `Required upstream work ${blockedByTitles.map((t4) => `"${t4}"`).join(", ")} failed`
325276
+ };
325277
+ blocked.set(node.id, record2);
325278
+ settled.add(node.id);
325279
+ await this.hooks.onNodeBlocked?.(node, record2);
325280
+ }
325281
+ };
325282
+ while (settled.size < nodes.length) {
325283
+ const ready = nodes.filter((node) => !settled.has(node.id) && (inDegree.get(node.id) ?? 0) === 0);
325202
325284
  if (ready.length === 0) {
325203
325285
  throw new Error("Dependency scheduler received a cyclic or incomplete task graph");
325204
325286
  }
325205
325287
  waves.push(ready.map((node) => node.id));
325206
325288
  await this.hooks.onWaveStart?.(ready, wave);
325289
+ const outcomes = /* @__PURE__ */ new Map();
325207
325290
  const runOne = async (node) => {
325208
325291
  const result = await this.hooks.execute(node);
325209
325292
  results.set(node.id, result);
325210
- completed.add(node.id);
325211
- for (const dependentId of dependents.get(node.id) ?? []) {
325212
- inDegree.set(dependentId, Math.max(0, (inDegree.get(dependentId) ?? 1) - 1));
325293
+ settled.add(node.id);
325294
+ const outcome = this.hooks.classify?.(node, result) ?? "succeeded";
325295
+ outcomes.set(node.id, outcome);
325296
+ if (outcome === "failed" && policy === "block") ; else {
325297
+ for (const dependentId of dependents.get(node.id) ?? []) {
325298
+ inDegree.set(dependentId, Math.max(0, (inDegree.get(dependentId) ?? 1) - 1));
325299
+ }
325213
325300
  }
325214
325301
  await this.hooks.onNodeComplete?.(node, result);
325215
325302
  };
@@ -325218,9 +325305,13 @@ var DependencyScheduler = class {
325218
325305
  } else {
325219
325306
  await Promise.all(ready.map(runOne));
325220
325307
  }
325308
+ const failedThisWave = ready.filter((node) => outcomes.get(node.id) === "failed").map((node) => node.id);
325309
+ if (policy === "block" && failedThisWave.length > 0) {
325310
+ await blockDescendants(failedThisWave);
325311
+ }
325221
325312
  wave++;
325222
325313
  }
325223
- return { results, waves };
325314
+ return { results, waves, blocked };
325224
325315
  }
325225
325316
  };
325226
325317
 
@@ -325321,6 +325412,9 @@ QUALITY RULES:
325321
325412
 
325322
325413
  ${generation}` : ""}`;
325323
325414
  }
325415
+ function producedOutput(result) {
325416
+ return result.status === "COMPLETED" || result.status === "PARTIAL";
325417
+ }
325324
325418
  var T1Administrator = class extends BaseTier {
325325
325419
  router;
325326
325420
  toolRegistry;
@@ -325455,7 +325549,7 @@ Board guidance (must be followed in the plan): ${decision.note}`,
325455
325549
  let allT2Results = await this.dispatchT2Managers(plan.sections);
325456
325550
  let pass = 1;
325457
325551
  const maxReplanPasses = this.config.maxReplanPasses ?? 2;
325458
- const okCount = (rs2) => rs2.filter((r4) => r4.status === "COMPLETED" || r4.status === "PARTIAL").length;
325552
+ const okCount = (rs2) => rs2.filter(producedOutput).length;
325459
325553
  while (pass <= maxReplanPasses) {
325460
325554
  const reviewResult = await this.reviewT2Outputs(enrichedPrompt, plan, allT2Results);
325461
325555
  if (reviewResult.approved) {
@@ -325586,7 +325680,7 @@ In 3-5 terse bullets, flag the most important RISKS, GAPS, or over-/under-decomp
325586
325680
  /** Structured, grounded summary of what's already done — used to keep
325587
325681
  * corrective replan passes from re-emitting completed sections. */
325588
325682
  summarizeCompletedSections(results) {
325589
- const done = results.filter((r4) => r4.status === "COMPLETED" || r4.status === "PARTIAL");
325683
+ const done = results.filter(producedOutput);
325590
325684
  if (done.length === 0) return "";
325591
325685
  const lines = done.map((r4) => `- "${r4.sectionTitle}" [${r4.status}]: ${(r4.sectionSummary || "(no summary)").slice(0, 300)}`);
325592
325686
  return `ALREADY COMPLETED SECTIONS (do not redo these \u2014 plan only what's still missing):
@@ -325731,6 +325825,7 @@ SPEC RULES \u2014 each subtask is a self-contained spec slice (workers execute f
325731
325825
  if (this.runBreaker) manager.setRunBreaker(this.runBreaker);
325732
325826
  if (this.escalationCallback) manager.setEscalationCallback(this.escalationCallback);
325733
325827
  manager.setHierarchyContext(`You are a T2 Manager for the section "${section.sectionTitle}". You are part of a COMPLEX task overseen by T1 Administrator.`);
325828
+ manager.setGraphPosition({ nodeId: section.sectionId, dependsOn: section.dependsOn ?? [] });
325734
325829
  if (this.store) {
325735
325830
  manager.setStore(this.store);
325736
325831
  }
@@ -325857,6 +325952,13 @@ SPEC RULES \u2014 each subtask is a self-contained spec slice (workers execute f
325857
325952
  const scheduler = new DependencyScheduler(
325858
325953
  compiled.graph,
325859
325954
  {
325955
+ // The wave is the scheduler's own counter, and it is the only place the
325956
+ // parallelism is visible: nodes sharing a wave ran at the same time.
325957
+ // Stamped before the wave executes so the first status event a node
325958
+ // emits already carries it.
325959
+ onWaveStart: (nodes, wave) => {
325960
+ for (const node of nodes) managers[node.ordinal]?.setGraphPosition({ wave });
325961
+ },
325860
325962
  execute: async (node) => {
325861
325963
  const section = node.payload;
325862
325964
  const manager = managers[node.ordinal];
@@ -325891,6 +325993,62 @@ SPEC RULES \u2014 each subtask is a self-contained spec slice (workers execute f
325891
325993
  };
325892
325994
  }
325893
325995
  },
325996
+ // PARTIAL is deliberately not a failure: a degraded-but-real section
325997
+ // can still feed the one after it, and cancelling that work would cost
325998
+ // the user more than letting it try.
325999
+ // BLOCKED cannot reach here today — the scheduler settles blocked nodes
326000
+ // through onNodeBlocked and never executes them, so classify only ever
326001
+ // sees results that ran. It is listed anyway because the failure mode if
326002
+ // that ever changes is silent and expensive: a blocked section would
326003
+ // classify as 'succeeded' and release its own dependents, so work that
326004
+ // should have been skipped would run and be billed.
326005
+ classify: (_node, result) => result.status === "FAILED" || result.status === "BLOCKED" ? "failed" : "succeeded",
326006
+ onNodeBlocked: (node, blockedInfo) => {
326007
+ const section = node.payload;
326008
+ this.log(`\u292B Skipped "${section.sectionTitle}" \u2014 ${blockedInfo.reason}`);
326009
+ resultMap.set(node.id, {
326010
+ sectionId: section.sectionId,
326011
+ sectionTitle: section.sectionTitle,
326012
+ // BLOCKED, not FAILED. This section was never attempted — saying it
326013
+ // failed reports a breakage that did not happen, and hides the one
326014
+ // that did (the upstream section named below).
326015
+ status: "BLOCKED",
326016
+ t3Results: [],
326017
+ sectionSummary: "",
326018
+ issues: [
326019
+ `${blockedInfo.reason}. This section was not attempted, so no tokens were spent on it.`
326020
+ ],
326021
+ blockedBy: { ids: [...blockedInfo.blockedBy], titles: [...blockedInfo.blockedByTitles] }
326022
+ });
326023
+ completedSections++;
326024
+ this.sendStatusUpdate({
326025
+ progressPct: 10 + Math.floor(completedSections / totalSections * 85),
326026
+ currentAction: `Skipped: ${section.sectionTitle} (blocked by an upstream failure)`,
326027
+ status: "IN_PROGRESS"
326028
+ });
326029
+ this.emit("tier:status", {
326030
+ tierId: section.sectionId,
326031
+ // A blocked section has no tier instance — it was never
326032
+ // constructed — so its runtime id and its graph id are the same
326033
+ // thing here. Emitting nodeId anyway keeps every node in the
326034
+ // stream addressable in the graph's id space, which is what
326035
+ // dependsOn edges point at.
326036
+ nodeId: section.sectionId,
326037
+ dependsOn: [...section.dependsOn ?? []],
326038
+ role: "T2",
326039
+ label: section.sectionTitle,
326040
+ status: "BLOCKED",
326041
+ blockedBy: [...blockedInfo.blockedByTitles],
326042
+ output: blockedInfo.reason
326043
+ });
326044
+ this.emit("section:complete", {
326045
+ id: node.id,
326046
+ title: section.sectionTitle,
326047
+ status: "BLOCKED",
326048
+ blockedBy: [...blockedInfo.blockedByTitles],
326049
+ output: ""
326050
+ });
326051
+ },
325894
326052
  onNodeComplete: (node, result) => {
325895
326053
  resultMap.set(node.id, result);
325896
326054
  completedSections++;
@@ -325907,14 +326065,18 @@ SPEC RULES \u2014 each subtask is a self-contained spec slice (workers execute f
325907
326065
  // intra-section T3 wave (t2-manager.ts) — 'sequential' otherwise only
325908
326066
  // serialized T3 workers within a single section while every section's
325909
326067
  // T2Manager (and its T3 workers) still ran in parallel here.
325910
- mode: this.router.getT3ExecutionMode?.() === "sequential" ? "sequential" : "parallel"
326068
+ mode: this.router.getT3ExecutionMode?.() === "sequential" ? "sequential" : "parallel",
326069
+ // A section that declared `dependsOn` said it needs that section's
326070
+ // output. Running it after the dependency failed produces a second
326071
+ // failure, billed, whose cause is the first one — so don't.
326072
+ onUpstreamFailure: "block"
325911
326073
  }
325912
326074
  );
325913
326075
  await scheduler.run();
325914
326076
  return sections.map((s3) => resultMap.get(s3.sectionId)).filter(Boolean);
325915
326077
  }
325916
326078
  async compileFinalOutput(originalPrompt, plan, t2Results) {
325917
- const completedSections = t2Results.filter((r4) => r4.status !== "FAILED");
326079
+ const completedSections = t2Results.filter(producedOutput);
325918
326080
  if (!completedSections.length) {
325919
326081
  const allIssues = [
325920
326082
  ...t2Results.flatMap((r4) => r4.issues ?? []),
@@ -325934,7 +326096,6 @@ ${r4.sectionSummary}
325934
326096
  Outputs:
325935
326097
  ${r4.t3Results.filter((t4) => t4.status === "COMPLETED").map((t4) => `\u2022 ${typeof t4.output === "string" ? t4.output : JSON.stringify(t4.output)}`).join("\n")}`).join("\n\n---\n\n");
325936
326098
  const openIssues = t2Results.flatMap((r4) => r4.issues).filter(Boolean);
325937
- t2Results.filter((r4) => r4.status === "FAILED" || r4.status === "PARTIAL");
325938
326099
  const compilePrompt = `Compile a final, coherent response to the user's original request.
325939
326100
 
325940
326101
  Original request: ${originalPrompt}
@@ -326426,6 +326587,7 @@ Model: ${asset.modelId} \u2014 ${r4.selected.reason}`;
326426
326587
  };
326427
326588
  var DEFAULT_VIDEO_SECONDS = 5;
326428
326589
  var MAX_VIDEO_SECONDS = 8;
326590
+ var MIN_VIDEO_SECONDS = 4;
326429
326591
  var GenerateVideoTool = class extends BaseTool {
326430
326592
  constructor(deps) {
326431
326593
  super();
@@ -326442,7 +326604,7 @@ var GenerateVideoTool = class extends BaseTool {
326442
326604
  type: "object",
326443
326605
  properties: {
326444
326606
  prompt: { type: "string", description: "What the video should show: subject, action, camera movement, style." },
326445
- seconds: { type: "number", description: `Clip length, 1-${MAX_VIDEO_SECONDS} seconds (default ${DEFAULT_VIDEO_SECONDS}). Billed per second \u2014 keep it short.` },
326607
+ seconds: { type: "number", description: `Clip length, ${MIN_VIDEO_SECONDS}-${MAX_VIDEO_SECONDS} seconds (default ${DEFAULT_VIDEO_SECONDS}). Billed per second \u2014 keep it short.` },
326446
326608
  aspectRatio: { type: "string", description: 'Frame shape: "16:9" (default) or "9:16" for vertical.' },
326447
326609
  model: { type: "string", description: "Optional: force a specific video model." }
326448
326610
  },
@@ -326454,7 +326616,7 @@ var GenerateVideoTool = class extends BaseTool {
326454
326616
  const r4 = resolve(this.deps, "video", input["model"] ? String(input["model"]) : void 0);
326455
326617
  if (!r4.ok) return r4.error;
326456
326618
  const asked = typeof input["seconds"] === "number" ? input["seconds"] : DEFAULT_VIDEO_SECONDS;
326457
- const seconds = Math.max(1, Math.min(MAX_VIDEO_SECONDS, Math.round(asked)));
326619
+ const seconds = Math.max(MIN_VIDEO_SECONDS, Math.min(MAX_VIDEO_SECONDS, Math.round(asked)));
326458
326620
  const asset = await generateVideo(
326459
326621
  r4.selected.capability,
326460
326622
  r4.cfg,
@@ -326471,7 +326633,7 @@ var GenerateVideoTool = class extends BaseTool {
326471
326633
  const location = await this.deps.sink(asset);
326472
326634
  const unit = r4.selected.cost;
326473
326635
  const estimate = unit ? ` Estimated cost: $${(unit.amount * seconds).toFixed(2)} (${seconds}s x ${unit.label}).` : "";
326474
- const clamped = seconds !== Math.round(asked) ? ` Duration was clamped to ${seconds}s (max ${MAX_VIDEO_SECONDS}).` : "";
326636
+ const clamped = seconds !== Math.round(asked) ? ` Duration was clamped to ${seconds}s (allowed ${MIN_VIDEO_SECONDS}-${MAX_VIDEO_SECONDS}).` : "";
326475
326637
  return `Video generated and saved to ${location}.
326476
326638
  Model: ${asset.modelId}.${estimate}${clamped}`;
326477
326639
  }
@@ -370519,7 +370681,24 @@ var TaskAnalyzer = class {
370519
370681
  feedback;
370520
370682
  bias;
370521
370683
  lastProfile = null;
370522
- lastSelectedModels = /* @__PURE__ */ new Map();
370684
+ /** Models chosen by the run currently in flight. Cleared when it completes. */
370685
+ currentRunSelections = /* @__PURE__ */ new Map();
370686
+ /**
370687
+ * Immutable snapshot of the last COMPLETED run: its selections AND the task
370688
+ * type they were chosen for.
370689
+ *
370690
+ * The task type has to travel WITH the selections. `lastProfile` is overwritten
370691
+ * by the next analyze(), so if run B started before the user rated run A, A's
370692
+ * models were credited under B's task type — teaching the router that a coding
370693
+ * model is good at creative writing, from a rating that never said so.
370694
+ *
370695
+ * Explicit ratings arrive after the run finishes — rateLastRun() is by nature
370696
+ * a reaction to a finished answer — but the run finalizer calls
370697
+ * recordRunOutcome() first, and that used to clear the only map there was. So
370698
+ * every explicit rating iterated an empty map, recorded nothing, and returned
370699
+ * false: the 3x-weighted user signal never reached the tracker at all.
370700
+ */
370701
+ lastCompletedRun;
370523
370702
  constructor(tracker, bias = "balanced") {
370524
370703
  this.tracker = tracker;
370525
370704
  this.bias = bias;
@@ -370566,11 +370745,15 @@ var TaskAnalyzer = class {
370566
370745
  */
370567
370746
  async selectModel(prompt, tier, selector, opts) {
370568
370747
  const profile = await this.analyze(prompt);
370748
+ const recordSelection = (model) => {
370749
+ if (model) this.currentRunSelections.set(tier, model);
370750
+ return model;
370751
+ };
370569
370752
  if (profile.requiresVision) {
370570
- return selector.selectVisionModel();
370753
+ return recordSelection(selector.selectVisionModel());
370571
370754
  }
370572
370755
  let candidates = selector.getCandidatesForTier(tier);
370573
- if (candidates.length === 0) return selector.selectForTier(tier);
370756
+ if (candidates.length === 0) return recordSelection(selector.selectForTier(tier));
370574
370757
  if (opts?.requiresToolUse) {
370575
370758
  const toolCapable = candidates.filter((m3) => m3.supportsToolUse !== false);
370576
370759
  if (toolCapable.length > 0) candidates = toolCapable;
@@ -370580,9 +370763,7 @@ var TaskAnalyzer = class {
370580
370763
  score: this.scoreModel(m3, profile)
370581
370764
  }));
370582
370765
  scored.sort((a2, b3) => b3.score - a2.score);
370583
- const best = scored[0]?.model ?? selector.selectForTier(tier);
370584
- if (best) this.lastSelectedModels.set(tier, best);
370585
- return best;
370766
+ return recordSelection(scored[0]?.model ?? selector.selectForTier(tier));
370586
370767
  }
370587
370768
  /**
370588
370769
  * Record the outcome of a completed run across all tiers that were selected
@@ -370591,25 +370772,38 @@ var TaskAnalyzer = class {
370591
370772
  recordRunOutcome(outcome, costByTier, contextTokens = 0) {
370592
370773
  if (!this.tracker || !this.lastProfile) return;
370593
370774
  const taskType = this.lastProfile.type;
370594
- for (const [tier, model] of this.lastSelectedModels) {
370775
+ for (const [tier, model] of this.currentRunSelections) {
370595
370776
  const cost = costByTier[tier] ?? 0;
370596
370777
  this.tracker.record(model.id, taskType, outcome, 0, cost, contextTokens);
370597
370778
  }
370598
- this.lastSelectedModels.clear();
370779
+ this.lastCompletedRun = { selections: new Map(this.currentRunSelections), taskType };
370780
+ this.currentRunSelections.clear();
370599
370781
  void this.tracker.save();
370600
370782
  }
370601
370783
  /**
370602
- * Record an explicit user rating (good/bad) for the last run's selected models.
370784
+ * Record an explicit user rating (good/bad) for the last COMPLETED run's models.
370603
370785
  * Explicit ratings carry 3× the weight of auto-detected outcomes.
370604
- * Does NOT clear lastSelectedModels — the auto record already did that.
370786
+ *
370787
+ * Reads the completed-run snapshot, not the in-flight map: by the time a user
370788
+ * can rate an answer, the finalizer has already run and the in-flight map is
370789
+ * empty.
370790
+ *
370791
+ * The snapshot is CONSUMED. recordExplicit() weights a rating 3x by recording
370792
+ * three samples, so a double submit would inject six — a stutter on the
370793
+ * button would count as two opinions. Consuming makes a second call a no-op
370794
+ * that reports false, which is what "rate this run" should mean.
370605
370795
  */
370606
370796
  recordExplicitRating(rating) {
370607
- if (!this.tracker || !this.lastProfile) return false;
370608
- const taskType = this.lastProfile.type;
370609
- for (const [, model] of this.lastSelectedModels) {
370610
- this.tracker.recordExplicit(model.id, taskType, rating, 0);
370611
- }
370612
- return this.lastSelectedModels.size > 0;
370797
+ const snapshot = this.lastCompletedRun;
370798
+ if (!this.tracker || !snapshot || snapshot.selections.size === 0) return false;
370799
+ const ratedModels = /* @__PURE__ */ new Set();
370800
+ for (const [, model] of snapshot.selections) {
370801
+ if (ratedModels.has(model.id)) continue;
370802
+ ratedModels.add(model.id);
370803
+ this.tracker.recordExplicit(model.id, snapshot.taskType, rating, 0);
370804
+ }
370805
+ this.lastCompletedRun = void 0;
370806
+ return true;
370613
370807
  }
370614
370808
  scoreModel(model, profile) {
370615
370809
  const perf = this.tracker?.performanceScore(model.id, profile.type) ?? 0.5;
@@ -371647,6 +371841,8 @@ var RESUME_SCHEMA_VERSION = 1;
371647
371841
  var DEFAULT_MAX_CHECKPOINTS = 5;
371648
371842
  var DEFAULT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1e3;
371649
371843
  var FILE_PATTERN = /^(\d+)-(.+)\.json$/;
371844
+ var CLAIM_SUFFIX = ".claimed";
371845
+ var DEFAULT_CLAIM_TIMEOUT_MS = 10 * 60 * 1e3;
371650
371846
  function safeId(taskId) {
371651
371847
  const cleaned = taskId.replace(/[^A-Za-z0-9._-]/g, "_").replace(/\.{2,}/g, "_");
371652
371848
  return cleaned.slice(0, 100) || "run";
@@ -371661,16 +371857,24 @@ var ResumeStore = class {
371661
371857
  maxCheckpoints;
371662
371858
  maxAgeMs;
371663
371859
  now;
371860
+ claimTimeoutMs;
371664
371861
  constructor(options) {
371665
371862
  this.dir = options.dir;
371666
371863
  this.maxCheckpoints = options.maxCheckpoints ?? DEFAULT_MAX_CHECKPOINTS;
371667
371864
  this.maxAgeMs = options.maxAgeMs ?? DEFAULT_MAX_AGE_MS;
371668
371865
  this.now = options.now ?? Date.now;
371866
+ this.claimTimeoutMs = options.claimTimeoutMs ?? DEFAULT_CLAIM_TIMEOUT_MS;
371669
371867
  }
371670
371868
  /**
371671
- * Persist a checkpoint and prune. Never throws: failing to save a resume file
371672
- * must not turn a recoverable stop into a crash, which would be a strictly
371673
- * worse outcome than the one this store exists to improve.
371869
+ * Persist a checkpoint and prune.
371870
+ *
371871
+ * Never throws — failing to save a resume file must not turn a recoverable
371872
+ * stop into a crash — but DOES report whether the atomic rename landed.
371873
+ * Callers decide whether an original claim is safe to discard based on that
371874
+ * answer, so "I swallowed the error" and "the checkpoint is on disk" must not
371875
+ * look alike to them.
371876
+ *
371877
+ * @returns true only when the checkpoint is durably on disk.
371674
371878
  */
371675
371879
  async save(checkpoint) {
371676
371880
  try {
@@ -371686,7 +371890,9 @@ var ResumeStore = class {
371686
371890
  await fs12.promises.writeFile(tempPath, JSON.stringify(record2, null, 2), "utf-8");
371687
371891
  await fs12.promises.rename(tempPath, finalPath);
371688
371892
  await this.prune();
371893
+ return true;
371689
371894
  } catch {
371895
+ return false;
371690
371896
  }
371691
371897
  }
371692
371898
  /** Valid, unexpired checkpoints, newest first. */
@@ -371716,15 +371922,87 @@ var ResumeStore = class {
371716
371922
  return (await this.list())[0] ?? null;
371717
371923
  }
371718
371924
  /**
371719
- * Load a checkpoint and delete it in the same step. Resuming twice from one
371720
- * checkpoint would re-run restored work, so consumption is part of reading.
371925
+ * Claim a checkpoint for a resume attempt.
371926
+ *
371927
+ * This used to DELETE the file and return it, which opened a window with no
371928
+ * recovery record at all: between preparing the continuation and the resumed
371929
+ * run writing its own checkpoint, a crash lost the only copy — and a crash is
371930
+ * precisely what this store exists to survive. Deleting on read optimised for
371931
+ * "never resume twice" at the cost of "never lose the work", which is the
371932
+ * wrong trade for a recovery mechanism.
371933
+ *
371934
+ * Instead the file is RENAMED to `.claimed`, which still hides it from
371935
+ * `list()` (so a concurrent resume cannot take the same work) but keeps it on
371936
+ * disk. The caller then either `release`s it — restoring it for another
371937
+ * attempt — or `settle`s it once the resumed run is safely under way. A claim
371938
+ * abandoned by a dead process is reclaimed after `claimTimeoutMs`.
371721
371939
  */
371722
- async consume(taskId) {
371940
+ async claim(taskId) {
371723
371941
  const all = await this.list();
371724
371942
  const target = taskId ? all.find((c4) => c4.taskId === taskId) : all[0];
371725
371943
  if (!target) return null;
371726
- await this.delete(target.taskId);
371727
- return target;
371944
+ try {
371945
+ const entries = await fs12.promises.readdir(this.dir);
371946
+ const wanted = safeId(target.taskId);
371947
+ const source = entries.find((name) => FILE_PATTERN.exec(name)?.[2] === wanted);
371948
+ if (!source) return null;
371949
+ const claimId = `${this.now()}-${safeId(target.taskId)}`;
371950
+ await fs12.promises.rename(path28__namespace.default.join(this.dir, source), path28__namespace.default.join(this.dir, `${claimId}${CLAIM_SUFFIX}`));
371951
+ return { checkpoint: target, claimId };
371952
+ } catch {
371953
+ return null;
371954
+ }
371955
+ }
371956
+ /** Give a claim back, so the work stays recoverable after a failed start. */
371957
+ async release(claimId) {
371958
+ try {
371959
+ await fs12.promises.rename(
371960
+ path28__namespace.default.join(this.dir, `${claimId}${CLAIM_SUFFIX}`),
371961
+ path28__namespace.default.join(this.dir, `${claimId}.json`)
371962
+ );
371963
+ } catch {
371964
+ }
371965
+ }
371966
+ /** Discard a claim for good — the resumed run has taken ownership. */
371967
+ async settle(claimId) {
371968
+ try {
371969
+ await fs12.promises.rm(path28__namespace.default.join(this.dir, `${claimId}${CLAIM_SUFFIX}`), { force: true });
371970
+ } catch {
371971
+ }
371972
+ }
371973
+ /**
371974
+ * Claim-and-discard in one step. Kept for callers that genuinely want the old
371975
+ * semantics; prefer claim/settle so a crash mid-resume stays recoverable.
371976
+ */
371977
+ async consume(taskId) {
371978
+ const claimed = await this.claim(taskId);
371979
+ if (!claimed) return null;
371980
+ await this.settle(claimed.claimId);
371981
+ return claimed.checkpoint;
371982
+ }
371983
+ /**
371984
+ * Return claims abandoned by a process that died mid-resume. Without this a
371985
+ * crash between claim and settle would strand the checkpoint invisibly —
371986
+ * trading one loss window for another.
371987
+ */
371988
+ async reclaimStale() {
371989
+ let reclaimed = 0;
371990
+ try {
371991
+ const entries = await fs12.promises.readdir(this.dir);
371992
+ const cutoff = this.now() - this.claimTimeoutMs;
371993
+ for (const name of entries) {
371994
+ if (!name.endsWith(CLAIM_SUFFIX)) continue;
371995
+ const stamp2 = Number(/^(\d+)-/.exec(name)?.[1]);
371996
+ if (!Number.isFinite(stamp2) || stamp2 >= cutoff) continue;
371997
+ await fs12.promises.rename(
371998
+ path28__namespace.default.join(this.dir, name),
371999
+ path28__namespace.default.join(this.dir, `${name.slice(0, -CLAIM_SUFFIX.length)}.json`)
372000
+ );
372001
+ reclaimed++;
372002
+ }
372003
+ } catch {
372004
+ }
372005
+ return reclaimed;
371728
372006
  }
371729
372007
  /** Remove every checkpoint for a task id. */
371730
372008
  async delete(taskId) {
@@ -372790,6 +373068,25 @@ var Cascade = class _Cascade extends EventEmitter__default.default {
372790
373068
  lastInterruptedRun;
372791
373069
  /** Durable resume checkpoints. See core/orchestration/resume-store.ts. */
372792
373070
  resumeStore;
373071
+ /** Claim held by an in-flight resume, settled once the run owns the work. */
373072
+ claimedResumeId;
373073
+ /**
373074
+ * Facts inherited from the checkpoint this run is resuming, so its own
373075
+ * checkpoint is cumulative rather than only covering this attempt.
373076
+ */
373077
+ resumedFrom;
373078
+ /** Whether THIS run persisted a checkpoint of its own. Drives settle-vs-release. */
373079
+ wroteCheckpointThisRun = false;
373080
+ /**
373081
+ * Whether the run reached the end of its normal path.
373082
+ *
373083
+ * NOT the same as `runError == null`: the cancellation and budget handlers
373084
+ * both null `runError` deliberately (an intentional stop is not a telemetry
373085
+ * error) and the breaker path never sets it at all. Treating that as success
373086
+ * meant an immediate cancel — no output, no replacement checkpoint — deleted
373087
+ * the original claim and the finished sections with it.
373088
+ */
373089
+ completedNormally = false;
372793
373090
  /** Section results of the CURRENT run, so an interruption can checkpoint them. */
372794
373091
  currentRunCompleted = [];
372795
373092
  initPromise;
@@ -373307,17 +373604,31 @@ ${input}` }],
373307
373604
  * worse. Nothing here is allowed to throw.
373308
373605
  */
373309
373606
  async checkpointRun(taskId, prompt, partialOutput, reason, detail) {
373310
- if (!this.currentRunCompleted.length && !partialOutput) return;
373607
+ const inherited = this.resumedFrom?.completed ?? [];
373608
+ const merged = /* @__PURE__ */ new Map();
373609
+ for (const node of inherited) merged.set(node.id, node);
373610
+ for (const node of this.currentRunCompleted) {
373611
+ const existing = merged.get(node.id);
373612
+ if (existing?.status === "COMPLETED" && node.status !== "COMPLETED") continue;
373613
+ merged.set(node.id, node);
373614
+ }
373615
+ const completed = [...merged.values()];
373616
+ const canonicalPrompt = this.resumedFrom?.prompt ?? prompt;
373617
+ const carriedPartial = partialOutput || (this.resumedFrom?.partialOutput ?? "");
373618
+ if (!completed.length && !carriedPartial) return false;
373311
373619
  try {
373312
- await this.resumeStore?.save({
373620
+ const saved = await this.resumeStore?.save({
373313
373621
  taskId,
373314
- prompt,
373622
+ prompt: canonicalPrompt,
373315
373623
  reason,
373316
373624
  ...detail ? { detail } : {},
373317
- partialOutput,
373318
- completed: [...this.currentRunCompleted]
373625
+ partialOutput: carriedPartial,
373626
+ completed
373319
373627
  });
373628
+ this.wroteCheckpointThisRun = saved === true;
373629
+ return saved === true;
373320
373630
  } catch {
373631
+ return false;
373321
373632
  }
373322
373633
  }
373323
373634
  /** True when a task can be resumed via /continue (this process or a previous one). */
@@ -373355,11 +373666,55 @@ ${input}` }],
373355
373666
  * checkpoint, so the same finished work is never restored into two runs.
373356
373667
  */
373357
373668
  async prepareDurableResume(opts = {}) {
373358
- const checkpoint = await this.resumeStore?.consume(opts.taskId);
373359
- if (!checkpoint) return null;
373360
- this.lastInterruptedRun = void 0;
373361
- this.raiseBudgetForResume(opts.maxTokens);
373362
- return this.buildResumePrompt(checkpoint.prompt, checkpoint.partialOutput, checkpoint.completed);
373669
+ await this.resumeStore?.reclaimStale();
373670
+ const claimed = await this.resumeStore?.claim(opts.taskId);
373671
+ if (!claimed) return null;
373672
+ try {
373673
+ this.lastInterruptedRun = void 0;
373674
+ this.raiseBudgetForResume(opts.maxTokens);
373675
+ const prompt = this.buildResumePrompt(
373676
+ claimed.checkpoint.prompt,
373677
+ claimed.checkpoint.partialOutput,
373678
+ claimed.checkpoint.completed
373679
+ );
373680
+ this.resumedFrom = {
373681
+ prompt: claimed.checkpoint.prompt,
373682
+ completed: claimed.checkpoint.completed,
373683
+ partialOutput: claimed.checkpoint.partialOutput
373684
+ };
373685
+ this.claimedResumeId = claimed.claimId;
373686
+ return prompt;
373687
+ } catch (err) {
373688
+ await this.resumeStore?.release(claimed.claimId);
373689
+ throw err;
373690
+ }
373691
+ }
373692
+ /**
373693
+ * Discard the claimed checkpoint once the resumed run has taken ownership of
373694
+ * the work (it has produced its own checkpoint, or finished). Called by the
373695
+ * run loop; safe to call when nothing is claimed.
373696
+ */
373697
+ async settleClaimedResume(succeeded) {
373698
+ const claimId = this.claimedResumeId;
373699
+ if (!claimId) return;
373700
+ this.claimedResumeId = void 0;
373701
+ if (succeeded || this.wroteCheckpointThisRun) {
373702
+ await this.resumeStore?.settle(claimId);
373703
+ } else {
373704
+ await this.resumeStore?.release(claimId);
373705
+ }
373706
+ }
373707
+ /**
373708
+ * Hand back a claim taken by prepareDurableResume() when the caller never
373709
+ * gets as far as run(). The REPL prepares the continuation and submits it
373710
+ * separately, so a failure in between would otherwise strand the checkpoint
373711
+ * for the whole lease timeout before reclaimStale() could recover it.
373712
+ */
373713
+ async releaseClaimedResume() {
373714
+ const claimId = this.claimedResumeId;
373715
+ if (!claimId) return;
373716
+ this.claimedResumeId = void 0;
373717
+ await this.resumeStore?.release(claimId);
373363
373718
  }
373364
373719
  /** A resume gets a bigger budget — the previous attempt exhausted the old one. */
373365
373720
  raiseBudgetForResume(maxTokens) {
@@ -373395,7 +373750,12 @@ ${partialOutput}` : ""
373395
373750
  async resumeRun(opts = {}) {
373396
373751
  const prompt = await this.prepareDurableResume(opts) ?? this.prepareResume(opts);
373397
373752
  if (!prompt) return null;
373398
- return this.run({ prompt });
373753
+ try {
373754
+ return await this.run({ prompt });
373755
+ } catch (err) {
373756
+ await this.releaseClaimedResume();
373757
+ throw err;
373758
+ }
373399
373759
  }
373400
373760
  getWorkspacePath() {
373401
373761
  return this.workspacePath;
@@ -373765,6 +374125,9 @@ ${prompt}` : prompt;
373765
374125
  const taskId = crypto6.randomUUID();
373766
374126
  this.decisionLog = [];
373767
374127
  this.currentRunCompleted = [];
374128
+ this.wroteCheckpointThisRun = false;
374129
+ this.completedNormally = false;
374130
+ if (!this.claimedResumeId) this.resumedFrom = void 0;
373768
374131
  if (options.fastAnswer) {
373769
374132
  return this.runFastAnswer(options, startMs, taskId);
373770
374133
  }
@@ -374043,6 +374406,7 @@ _(incomplete: ${reasons.join("; ")})_` : partial2;
374043
374406
  ${finalOutput}` : ""
374044
374407
  ].join("\n").trimEnd();
374045
374408
  }
374409
+ this.completedNormally = trip == null;
374046
374410
  } catch (err) {
374047
374411
  if (err instanceof CascadeCancelledError || err instanceof Error && err.name === "AbortError" || options.signal?.aborted) {
374048
374412
  this.emit("run:cancelled", {
@@ -374084,6 +374448,10 @@ ${finalOutput}` : ""
374084
374448
  escalator.cancelAllPending();
374085
374449
  } catch {
374086
374450
  }
374451
+ try {
374452
+ await this.settleClaimedResume(this.completedNormally);
374453
+ } catch {
374454
+ }
374087
374455
  try {
374088
374456
  this.releasePendingEscalations();
374089
374457
  } catch {
@@ -376493,8 +376861,14 @@ var DashboardSocket = class {
376493
376861
  emitCascadeEvent(ev) {
376494
376862
  this.io.emit("cascade:event", ev);
376495
376863
  }
376496
- emitTierStatus(tierId, role, status, sessionId, action) {
376497
- const payload = { tierId, role, status, action, timestamp: (/* @__PURE__ */ new Date()).toISOString(), sessionId };
376864
+ /**
376865
+ * `extra` exists because this signature is positional: every field the tier
376866
+ * emits that is not named here is silently dropped on this path, while the
376867
+ * other two forwarders spread the whole event through. That asymmetry is how
376868
+ * a field can reach the web dashboard from one code path and not another.
376869
+ */
376870
+ emitTierStatus(tierId, role, status, sessionId, action, extra) {
376871
+ const payload = { tierId, role, status, action, timestamp: (/* @__PURE__ */ new Date()).toISOString(), sessionId, ...extra };
376498
376872
  this.io.emit("tier:status", payload);
376499
376873
  this.io.to(`session:${sessionId}`).emit("tier:status", payload);
376500
376874
  }