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/cli.js CHANGED
@@ -1757,7 +1757,7 @@ var CASCADE_VERSION, CASCADE_CONFIG_FILE, CASCADE_DB_FILE, CASCADE_DASHBOARD_SEC
1757
1757
  var init_constants = __esm({
1758
1758
  "src/constants.ts"() {
1759
1759
  init_pricing();
1760
- CASCADE_VERSION = "0.68.0";
1760
+ CASCADE_VERSION = "0.69.0";
1761
1761
  CASCADE_CONFIG_FILE = ".cascade/config.json";
1762
1762
  CASCADE_DB_FILE = ".cascade/memory.db";
1763
1763
  CASCADE_DASHBOARD_SECRET_FILE = ".cascade/dashboard-secret";
@@ -8482,6 +8482,21 @@ var BaseTier = class extends EventEmitter {
8482
8482
  * which node (Cockpit node panel / Why panel).
8483
8483
  */
8484
8484
  servingModel;
8485
+ /**
8486
+ * This tier's identity IN THE TASK GRAPH — the section or subtask id the
8487
+ * planner assigned — plus where it sat in the dependency order.
8488
+ *
8489
+ * `id` and `graphNodeId` are deliberately different id spaces, and both are
8490
+ * needed. `id` identifies this tier INSTANCE ("T2_a1b2c3d4", minted per
8491
+ * construction); `graphNodeId` identifies the WORK ("s1"), which is what
8492
+ * dependency edges point at. Without the distinction a graph view cannot be
8493
+ * drawn at all: `dependsOn` lists section ids, while every running tier
8494
+ * reports a generated tier id, so every edge would name a node that never
8495
+ * appears in the stream.
8496
+ */
8497
+ graphNodeId;
8498
+ graphDependsOn;
8499
+ graphWave;
8485
8500
  constructor(role, id, parentId) {
8486
8501
  super();
8487
8502
  this.role = role;
@@ -8493,6 +8508,33 @@ var BaseTier = class extends EventEmitter {
8493
8508
  setPresenter(on = true) {
8494
8509
  this.isPresenter = on;
8495
8510
  }
8511
+ /**
8512
+ * Record where this tier sits in the compiled task graph.
8513
+ *
8514
+ * Called by whichever tier scheduled this one: the id and edges are known at
8515
+ * construction, the wave only once the scheduler reaches it, so the fields
8516
+ * are set independently rather than all at once.
8517
+ */
8518
+ setGraphPosition(position) {
8519
+ if (position.nodeId !== void 0) this.graphNodeId = position.nodeId;
8520
+ if (position.dependsOn !== void 0) this.graphDependsOn = [...position.dependsOn];
8521
+ if (position.wave !== void 0) this.graphWave = position.wave;
8522
+ }
8523
+ /**
8524
+ * The graph fields, for spreading into a status payload.
8525
+ *
8526
+ * One helper rather than two literals because `tier:status` is emitted from
8527
+ * two different call sites with two different payload shapes; adding a field
8528
+ * to only one of them makes it arrive intermittently, which is worse for a
8529
+ * consumer than never arriving at all.
8530
+ */
8531
+ graphFields() {
8532
+ return {
8533
+ nodeId: this.graphNodeId,
8534
+ dependsOn: this.graphDependsOn ? [...this.graphDependsOn] : void 0,
8535
+ waveId: this.graphWave
8536
+ };
8537
+ }
8496
8538
  getStatus() {
8497
8539
  return this.status;
8498
8540
  }
@@ -8507,7 +8549,8 @@ var BaseTier = class extends EventEmitter {
8507
8549
  status,
8508
8550
  timestamp,
8509
8551
  output,
8510
- model: this.servingModel
8552
+ model: this.servingModel,
8553
+ ...this.graphFields()
8511
8554
  };
8512
8555
  this.emit("status", event);
8513
8556
  this.emit("tier:status", event);
@@ -8547,7 +8590,8 @@ var BaseTier = class extends EventEmitter {
8547
8590
  progressPct: update.progressPct,
8548
8591
  timestamp,
8549
8592
  output: update.output,
8550
- model: this.servingModel
8593
+ model: this.servingModel,
8594
+ ...this.graphFields()
8551
8595
  });
8552
8596
  }
8553
8597
  buildMessage(type, to, payload) {
@@ -9450,7 +9494,8 @@ Now execute your subtask using this context where relevant.`
9450
9494
  if (result2.verdict === "passed") passed.push(result2.criterion);
9451
9495
  }
9452
9496
  this.sendStatusUpdate({ progressPct: 70, currentAction: "Self-testing output", status: "IN_PROGRESS" });
9453
- const testResult = await this.selfTest(assignment, output, undecided(acceptanceResults));
9497
+ const pendingAcceptance = undecided(acceptanceResults);
9498
+ const testResult = await this.selfTest(assignment, output, pendingAcceptance);
9454
9499
  checksRun.push(...testResult.checksRun);
9455
9500
  passed.push(...testResult.passed);
9456
9501
  failed.push(...testResult.failed);
@@ -9459,7 +9504,7 @@ Now execute your subtask using this context where relevant.`
9459
9504
  issues.push(`Initial check failed: ${testResult.failed.join(", ")}`);
9460
9505
  const corrected = await this.correctOutput(output, testResult.failed);
9461
9506
  output = corrected;
9462
- const retest = await this.selfTest(assignment, output);
9507
+ const retest = await this.selfTest(assignment, output, pendingAcceptance);
9463
9508
  passed.push(...retest.passed);
9464
9509
  if (retest.failed.length > 0) {
9465
9510
  failed.push(...retest.failed);
@@ -11384,6 +11429,7 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
11384
11429
  for (const a of assignments) {
11385
11430
  const worker = new T3Worker(this.router, this.toolRegistry, this.id);
11386
11431
  if (this.store) worker.setStore(this.store, taskId);
11432
+ worker.setGraphPosition({ nodeId: a.subtaskId, dependsOn: a.dependsOn ?? [] });
11387
11433
  worker.setPeerBus(this.t3PeerBus);
11388
11434
  if (this.permissionEscalator) worker.setPermissionEscalator(this.permissionEscalator);
11389
11435
  if (this.toolCreator) worker.setToolCreator(this.toolCreator);
@@ -11504,6 +11550,7 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
11504
11550
  runnableIds.push(fallbackId);
11505
11551
  }
11506
11552
  wave++;
11553
+ for (const id of runnableIds) workerMap.get(id)?.setGraphPosition({ wave });
11507
11554
  this.log(`Wave ${wave}: running ${runnableIds.length} subtask(s) in parallel`);
11508
11555
  this.sendStatusUpdate({
11509
11556
  progressPct: 20 + Math.min(wave * 10, 60),
@@ -11834,23 +11881,63 @@ var DependencyScheduler = class {
11834
11881
  dependents.get(dependencyId)?.push(node.id);
11835
11882
  }
11836
11883
  }
11837
- const completed = /* @__PURE__ */ new Set();
11884
+ const policy = this.options.onUpstreamFailure ?? "continue";
11885
+ const settled = /* @__PURE__ */ new Set();
11838
11886
  const results = /* @__PURE__ */ new Map();
11887
+ const blocked = /* @__PURE__ */ new Map();
11839
11888
  const waves = [];
11840
11889
  let wave = 0;
11841
- while (completed.size < nodes.length) {
11842
- const ready = nodes.filter((node) => !completed.has(node.id) && (inDegree.get(node.id) ?? 0) === 0);
11890
+ const blockDescendants = async (failedIds) => {
11891
+ const rootsFor = /* @__PURE__ */ new Map();
11892
+ const queue = [];
11893
+ for (const rootId of failedIds) {
11894
+ for (const childId of dependents.get(rootId) ?? []) queue.push({ id: childId, root: rootId });
11895
+ }
11896
+ while (queue.length > 0) {
11897
+ const { id, root } = queue.shift();
11898
+ if (settled.has(id)) continue;
11899
+ const known = rootsFor.get(id);
11900
+ if (known) {
11901
+ if (!known.has(root)) known.add(root);
11902
+ continue;
11903
+ }
11904
+ rootsFor.set(id, /* @__PURE__ */ new Set([root]));
11905
+ for (const childId of dependents.get(id) ?? []) queue.push({ id: childId, root });
11906
+ }
11907
+ for (const node of nodes) {
11908
+ const roots = rootsFor.get(node.id);
11909
+ if (!roots || settled.has(node.id)) continue;
11910
+ const causes = nodes.filter((n) => roots.has(n.id));
11911
+ const blockedBy = causes.map((n) => n.id);
11912
+ const blockedByTitles = causes.map((n) => n.title || n.id);
11913
+ const record = {
11914
+ blockedBy,
11915
+ blockedByTitles,
11916
+ reason: blockedByTitles.length === 1 ? `Required upstream work "${blockedByTitles[0]}" failed` : `Required upstream work ${blockedByTitles.map((t) => `"${t}"`).join(", ")} failed`
11917
+ };
11918
+ blocked.set(node.id, record);
11919
+ settled.add(node.id);
11920
+ await this.hooks.onNodeBlocked?.(node, record);
11921
+ }
11922
+ };
11923
+ while (settled.size < nodes.length) {
11924
+ const ready = nodes.filter((node) => !settled.has(node.id) && (inDegree.get(node.id) ?? 0) === 0);
11843
11925
  if (ready.length === 0) {
11844
11926
  throw new Error("Dependency scheduler received a cyclic or incomplete task graph");
11845
11927
  }
11846
11928
  waves.push(ready.map((node) => node.id));
11847
11929
  await this.hooks.onWaveStart?.(ready, wave);
11930
+ const outcomes = /* @__PURE__ */ new Map();
11848
11931
  const runOne = async (node) => {
11849
11932
  const result = await this.hooks.execute(node);
11850
11933
  results.set(node.id, result);
11851
- completed.add(node.id);
11852
- for (const dependentId of dependents.get(node.id) ?? []) {
11853
- inDegree.set(dependentId, Math.max(0, (inDegree.get(dependentId) ?? 1) - 1));
11934
+ settled.add(node.id);
11935
+ const outcome = this.hooks.classify?.(node, result) ?? "succeeded";
11936
+ outcomes.set(node.id, outcome);
11937
+ if (outcome === "failed" && policy === "block") ; else {
11938
+ for (const dependentId of dependents.get(node.id) ?? []) {
11939
+ inDegree.set(dependentId, Math.max(0, (inDegree.get(dependentId) ?? 1) - 1));
11940
+ }
11854
11941
  }
11855
11942
  await this.hooks.onNodeComplete?.(node, result);
11856
11943
  };
@@ -11859,9 +11946,13 @@ var DependencyScheduler = class {
11859
11946
  } else {
11860
11947
  await Promise.all(ready.map(runOne));
11861
11948
  }
11949
+ const failedThisWave = ready.filter((node) => outcomes.get(node.id) === "failed").map((node) => node.id);
11950
+ if (policy === "block" && failedThisWave.length > 0) {
11951
+ await blockDescendants(failedThisWave);
11952
+ }
11862
11953
  wave++;
11863
11954
  }
11864
- return { results, waves };
11955
+ return { results, waves, blocked };
11865
11956
  }
11866
11957
  };
11867
11958
 
@@ -11962,6 +12053,9 @@ QUALITY RULES:
11962
12053
 
11963
12054
  ${generation}` : ""}`;
11964
12055
  }
12056
+ function producedOutput(result) {
12057
+ return result.status === "COMPLETED" || result.status === "PARTIAL";
12058
+ }
11965
12059
  var T1Administrator = class extends BaseTier {
11966
12060
  router;
11967
12061
  toolRegistry;
@@ -12096,7 +12190,7 @@ Board guidance (must be followed in the plan): ${decision.note}`,
12096
12190
  let allT2Results = await this.dispatchT2Managers(plan.sections);
12097
12191
  let pass = 1;
12098
12192
  const maxReplanPasses = this.config.maxReplanPasses ?? 2;
12099
- const okCount = (rs) => rs.filter((r) => r.status === "COMPLETED" || r.status === "PARTIAL").length;
12193
+ const okCount = (rs) => rs.filter(producedOutput).length;
12100
12194
  while (pass <= maxReplanPasses) {
12101
12195
  const reviewResult = await this.reviewT2Outputs(enrichedPrompt, plan, allT2Results);
12102
12196
  if (reviewResult.approved) {
@@ -12227,7 +12321,7 @@ In 3-5 terse bullets, flag the most important RISKS, GAPS, or over-/under-decomp
12227
12321
  /** Structured, grounded summary of what's already done — used to keep
12228
12322
  * corrective replan passes from re-emitting completed sections. */
12229
12323
  summarizeCompletedSections(results) {
12230
- const done = results.filter((r) => r.status === "COMPLETED" || r.status === "PARTIAL");
12324
+ const done = results.filter(producedOutput);
12231
12325
  if (done.length === 0) return "";
12232
12326
  const lines = done.map((r) => `- "${r.sectionTitle}" [${r.status}]: ${(r.sectionSummary || "(no summary)").slice(0, 300)}`);
12233
12327
  return `ALREADY COMPLETED SECTIONS (do not redo these \u2014 plan only what's still missing):
@@ -12372,6 +12466,7 @@ SPEC RULES \u2014 each subtask is a self-contained spec slice (workers execute f
12372
12466
  if (this.runBreaker) manager.setRunBreaker(this.runBreaker);
12373
12467
  if (this.escalationCallback) manager.setEscalationCallback(this.escalationCallback);
12374
12468
  manager.setHierarchyContext(`You are a T2 Manager for the section "${section.sectionTitle}". You are part of a COMPLEX task overseen by T1 Administrator.`);
12469
+ manager.setGraphPosition({ nodeId: section.sectionId, dependsOn: section.dependsOn ?? [] });
12375
12470
  if (this.store) {
12376
12471
  manager.setStore(this.store);
12377
12472
  }
@@ -12498,6 +12593,13 @@ SPEC RULES \u2014 each subtask is a self-contained spec slice (workers execute f
12498
12593
  const scheduler = new DependencyScheduler(
12499
12594
  compiled.graph,
12500
12595
  {
12596
+ // The wave is the scheduler's own counter, and it is the only place the
12597
+ // parallelism is visible: nodes sharing a wave ran at the same time.
12598
+ // Stamped before the wave executes so the first status event a node
12599
+ // emits already carries it.
12600
+ onWaveStart: (nodes, wave) => {
12601
+ for (const node of nodes) managers[node.ordinal]?.setGraphPosition({ wave });
12602
+ },
12501
12603
  execute: async (node) => {
12502
12604
  const section = node.payload;
12503
12605
  const manager = managers[node.ordinal];
@@ -12532,6 +12634,62 @@ SPEC RULES \u2014 each subtask is a self-contained spec slice (workers execute f
12532
12634
  };
12533
12635
  }
12534
12636
  },
12637
+ // PARTIAL is deliberately not a failure: a degraded-but-real section
12638
+ // can still feed the one after it, and cancelling that work would cost
12639
+ // the user more than letting it try.
12640
+ // BLOCKED cannot reach here today — the scheduler settles blocked nodes
12641
+ // through onNodeBlocked and never executes them, so classify only ever
12642
+ // sees results that ran. It is listed anyway because the failure mode if
12643
+ // that ever changes is silent and expensive: a blocked section would
12644
+ // classify as 'succeeded' and release its own dependents, so work that
12645
+ // should have been skipped would run and be billed.
12646
+ classify: (_node, result) => result.status === "FAILED" || result.status === "BLOCKED" ? "failed" : "succeeded",
12647
+ onNodeBlocked: (node, blockedInfo) => {
12648
+ const section = node.payload;
12649
+ this.log(`\u292B Skipped "${section.sectionTitle}" \u2014 ${blockedInfo.reason}`);
12650
+ resultMap.set(node.id, {
12651
+ sectionId: section.sectionId,
12652
+ sectionTitle: section.sectionTitle,
12653
+ // BLOCKED, not FAILED. This section was never attempted — saying it
12654
+ // failed reports a breakage that did not happen, and hides the one
12655
+ // that did (the upstream section named below).
12656
+ status: "BLOCKED",
12657
+ t3Results: [],
12658
+ sectionSummary: "",
12659
+ issues: [
12660
+ `${blockedInfo.reason}. This section was not attempted, so no tokens were spent on it.`
12661
+ ],
12662
+ blockedBy: { ids: [...blockedInfo.blockedBy], titles: [...blockedInfo.blockedByTitles] }
12663
+ });
12664
+ completedSections++;
12665
+ this.sendStatusUpdate({
12666
+ progressPct: 10 + Math.floor(completedSections / totalSections * 85),
12667
+ currentAction: `Skipped: ${section.sectionTitle} (blocked by an upstream failure)`,
12668
+ status: "IN_PROGRESS"
12669
+ });
12670
+ this.emit("tier:status", {
12671
+ tierId: section.sectionId,
12672
+ // A blocked section has no tier instance — it was never
12673
+ // constructed — so its runtime id and its graph id are the same
12674
+ // thing here. Emitting nodeId anyway keeps every node in the
12675
+ // stream addressable in the graph's id space, which is what
12676
+ // dependsOn edges point at.
12677
+ nodeId: section.sectionId,
12678
+ dependsOn: [...section.dependsOn ?? []],
12679
+ role: "T2",
12680
+ label: section.sectionTitle,
12681
+ status: "BLOCKED",
12682
+ blockedBy: [...blockedInfo.blockedByTitles],
12683
+ output: blockedInfo.reason
12684
+ });
12685
+ this.emit("section:complete", {
12686
+ id: node.id,
12687
+ title: section.sectionTitle,
12688
+ status: "BLOCKED",
12689
+ blockedBy: [...blockedInfo.blockedByTitles],
12690
+ output: ""
12691
+ });
12692
+ },
12535
12693
  onNodeComplete: (node, result) => {
12536
12694
  resultMap.set(node.id, result);
12537
12695
  completedSections++;
@@ -12548,14 +12706,18 @@ SPEC RULES \u2014 each subtask is a self-contained spec slice (workers execute f
12548
12706
  // intra-section T3 wave (t2-manager.ts) — 'sequential' otherwise only
12549
12707
  // serialized T3 workers within a single section while every section's
12550
12708
  // T2Manager (and its T3 workers) still ran in parallel here.
12551
- mode: this.router.getT3ExecutionMode?.() === "sequential" ? "sequential" : "parallel"
12709
+ mode: this.router.getT3ExecutionMode?.() === "sequential" ? "sequential" : "parallel",
12710
+ // A section that declared `dependsOn` said it needs that section's
12711
+ // output. Running it after the dependency failed produces a second
12712
+ // failure, billed, whose cause is the first one — so don't.
12713
+ onUpstreamFailure: "block"
12552
12714
  }
12553
12715
  );
12554
12716
  await scheduler.run();
12555
12717
  return sections.map((s) => resultMap.get(s.sectionId)).filter(Boolean);
12556
12718
  }
12557
12719
  async compileFinalOutput(originalPrompt, plan, t2Results) {
12558
- const completedSections = t2Results.filter((r) => r.status !== "FAILED");
12720
+ const completedSections = t2Results.filter(producedOutput);
12559
12721
  if (!completedSections.length) {
12560
12722
  const allIssues = [
12561
12723
  ...t2Results.flatMap((r) => r.issues ?? []),
@@ -12575,7 +12737,6 @@ ${r.sectionSummary}
12575
12737
  Outputs:
12576
12738
  ${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");
12577
12739
  const openIssues = t2Results.flatMap((r) => r.issues).filter(Boolean);
12578
- t2Results.filter((r) => r.status === "FAILED" || r.status === "PARTIAL");
12579
12740
  const compilePrompt = `Compile a final, coherent response to the user's original request.
12580
12741
 
12581
12742
  Original request: ${originalPrompt}
@@ -13070,6 +13231,7 @@ Model: ${asset.modelId} \u2014 ${r.selected.reason}`;
13070
13231
  };
13071
13232
  var DEFAULT_VIDEO_SECONDS = 5;
13072
13233
  var MAX_VIDEO_SECONDS = 8;
13234
+ var MIN_VIDEO_SECONDS = 4;
13073
13235
  var GenerateVideoTool = class extends BaseTool {
13074
13236
  constructor(deps) {
13075
13237
  super();
@@ -13086,7 +13248,7 @@ var GenerateVideoTool = class extends BaseTool {
13086
13248
  type: "object",
13087
13249
  properties: {
13088
13250
  prompt: { type: "string", description: "What the video should show: subject, action, camera movement, style." },
13089
- seconds: { type: "number", description: `Clip length, 1-${MAX_VIDEO_SECONDS} seconds (default ${DEFAULT_VIDEO_SECONDS}). Billed per second \u2014 keep it short.` },
13251
+ seconds: { type: "number", description: `Clip length, ${MIN_VIDEO_SECONDS}-${MAX_VIDEO_SECONDS} seconds (default ${DEFAULT_VIDEO_SECONDS}). Billed per second \u2014 keep it short.` },
13090
13252
  aspectRatio: { type: "string", description: 'Frame shape: "16:9" (default) or "9:16" for vertical.' },
13091
13253
  model: { type: "string", description: "Optional: force a specific video model." }
13092
13254
  },
@@ -13098,7 +13260,7 @@ var GenerateVideoTool = class extends BaseTool {
13098
13260
  const r = resolve(this.deps, "video", input["model"] ? String(input["model"]) : void 0);
13099
13261
  if (!r.ok) return r.error;
13100
13262
  const asked = typeof input["seconds"] === "number" ? input["seconds"] : DEFAULT_VIDEO_SECONDS;
13101
- const seconds = Math.max(1, Math.min(MAX_VIDEO_SECONDS, Math.round(asked)));
13263
+ const seconds = Math.max(MIN_VIDEO_SECONDS, Math.min(MAX_VIDEO_SECONDS, Math.round(asked)));
13102
13264
  const asset = await generateVideo(
13103
13265
  r.selected.capability,
13104
13266
  r.cfg,
@@ -13115,7 +13277,7 @@ var GenerateVideoTool = class extends BaseTool {
13115
13277
  const location = await this.deps.sink(asset);
13116
13278
  const unit = r.selected.cost;
13117
13279
  const estimate = unit ? ` Estimated cost: $${(unit.amount * seconds).toFixed(2)} (${seconds}s x ${unit.label}).` : "";
13118
- const clamped = seconds !== Math.round(asked) ? ` Duration was clamped to ${seconds}s (max ${MAX_VIDEO_SECONDS}).` : "";
13280
+ const clamped = seconds !== Math.round(asked) ? ` Duration was clamped to ${seconds}s (allowed ${MIN_VIDEO_SECONDS}-${MAX_VIDEO_SECONDS}).` : "";
13119
13281
  return `Video generated and saved to ${location}.
13120
13282
  Model: ${asset.modelId}.${estimate}${clamped}`;
13121
13283
  }
@@ -16256,7 +16418,24 @@ var TaskAnalyzer = class {
16256
16418
  feedback;
16257
16419
  bias;
16258
16420
  lastProfile = null;
16259
- lastSelectedModels = /* @__PURE__ */ new Map();
16421
+ /** Models chosen by the run currently in flight. Cleared when it completes. */
16422
+ currentRunSelections = /* @__PURE__ */ new Map();
16423
+ /**
16424
+ * Immutable snapshot of the last COMPLETED run: its selections AND the task
16425
+ * type they were chosen for.
16426
+ *
16427
+ * The task type has to travel WITH the selections. `lastProfile` is overwritten
16428
+ * by the next analyze(), so if run B started before the user rated run A, A's
16429
+ * models were credited under B's task type — teaching the router that a coding
16430
+ * model is good at creative writing, from a rating that never said so.
16431
+ *
16432
+ * Explicit ratings arrive after the run finishes — rateLastRun() is by nature
16433
+ * a reaction to a finished answer — but the run finalizer calls
16434
+ * recordRunOutcome() first, and that used to clear the only map there was. So
16435
+ * every explicit rating iterated an empty map, recorded nothing, and returned
16436
+ * false: the 3x-weighted user signal never reached the tracker at all.
16437
+ */
16438
+ lastCompletedRun;
16260
16439
  constructor(tracker, bias = "balanced") {
16261
16440
  this.tracker = tracker;
16262
16441
  this.bias = bias;
@@ -16303,11 +16482,15 @@ var TaskAnalyzer = class {
16303
16482
  */
16304
16483
  async selectModel(prompt, tier, selector, opts) {
16305
16484
  const profile = await this.analyze(prompt);
16485
+ const recordSelection = (model) => {
16486
+ if (model) this.currentRunSelections.set(tier, model);
16487
+ return model;
16488
+ };
16306
16489
  if (profile.requiresVision) {
16307
- return selector.selectVisionModel();
16490
+ return recordSelection(selector.selectVisionModel());
16308
16491
  }
16309
16492
  let candidates2 = selector.getCandidatesForTier(tier);
16310
- if (candidates2.length === 0) return selector.selectForTier(tier);
16493
+ if (candidates2.length === 0) return recordSelection(selector.selectForTier(tier));
16311
16494
  if (opts?.requiresToolUse) {
16312
16495
  const toolCapable = candidates2.filter((m) => m.supportsToolUse !== false);
16313
16496
  if (toolCapable.length > 0) candidates2 = toolCapable;
@@ -16317,9 +16500,7 @@ var TaskAnalyzer = class {
16317
16500
  score: this.scoreModel(m, profile)
16318
16501
  }));
16319
16502
  scored.sort((a, b) => b.score - a.score);
16320
- const best = scored[0]?.model ?? selector.selectForTier(tier);
16321
- if (best) this.lastSelectedModels.set(tier, best);
16322
- return best;
16503
+ return recordSelection(scored[0]?.model ?? selector.selectForTier(tier));
16323
16504
  }
16324
16505
  /**
16325
16506
  * Record the outcome of a completed run across all tiers that were selected
@@ -16328,25 +16509,38 @@ var TaskAnalyzer = class {
16328
16509
  recordRunOutcome(outcome, costByTier, contextTokens = 0) {
16329
16510
  if (!this.tracker || !this.lastProfile) return;
16330
16511
  const taskType = this.lastProfile.type;
16331
- for (const [tier, model] of this.lastSelectedModels) {
16512
+ for (const [tier, model] of this.currentRunSelections) {
16332
16513
  const cost = costByTier[tier] ?? 0;
16333
16514
  this.tracker.record(model.id, taskType, outcome, 0, cost, contextTokens);
16334
16515
  }
16335
- this.lastSelectedModels.clear();
16516
+ this.lastCompletedRun = { selections: new Map(this.currentRunSelections), taskType };
16517
+ this.currentRunSelections.clear();
16336
16518
  void this.tracker.save();
16337
16519
  }
16338
16520
  /**
16339
- * Record an explicit user rating (good/bad) for the last run's selected models.
16521
+ * Record an explicit user rating (good/bad) for the last COMPLETED run's models.
16340
16522
  * Explicit ratings carry 3× the weight of auto-detected outcomes.
16341
- * Does NOT clear lastSelectedModels — the auto record already did that.
16523
+ *
16524
+ * Reads the completed-run snapshot, not the in-flight map: by the time a user
16525
+ * can rate an answer, the finalizer has already run and the in-flight map is
16526
+ * empty.
16527
+ *
16528
+ * The snapshot is CONSUMED. recordExplicit() weights a rating 3x by recording
16529
+ * three samples, so a double submit would inject six — a stutter on the
16530
+ * button would count as two opinions. Consuming makes a second call a no-op
16531
+ * that reports false, which is what "rate this run" should mean.
16342
16532
  */
16343
16533
  recordExplicitRating(rating) {
16344
- if (!this.tracker || !this.lastProfile) return false;
16345
- const taskType = this.lastProfile.type;
16346
- for (const [, model] of this.lastSelectedModels) {
16347
- this.tracker.recordExplicit(model.id, taskType, rating, 0);
16348
- }
16349
- return this.lastSelectedModels.size > 0;
16534
+ const snapshot = this.lastCompletedRun;
16535
+ if (!this.tracker || !snapshot || snapshot.selections.size === 0) return false;
16536
+ const ratedModels = /* @__PURE__ */ new Set();
16537
+ for (const [, model] of snapshot.selections) {
16538
+ if (ratedModels.has(model.id)) continue;
16539
+ ratedModels.add(model.id);
16540
+ this.tracker.recordExplicit(model.id, snapshot.taskType, rating, 0);
16541
+ }
16542
+ this.lastCompletedRun = void 0;
16543
+ return true;
16350
16544
  }
16351
16545
  scoreModel(model, profile) {
16352
16546
  const perf = this.tracker?.performanceScore(model.id, profile.type) ?? 0.5;
@@ -17387,6 +17581,8 @@ var RESUME_SCHEMA_VERSION = 1;
17387
17581
  var DEFAULT_MAX_CHECKPOINTS = 5;
17388
17582
  var DEFAULT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1e3;
17389
17583
  var FILE_PATTERN = /^(\d+)-(.+)\.json$/;
17584
+ var CLAIM_SUFFIX = ".claimed";
17585
+ var DEFAULT_CLAIM_TIMEOUT_MS = 10 * 60 * 1e3;
17390
17586
  function safeId(taskId) {
17391
17587
  const cleaned = taskId.replace(/[^A-Za-z0-9._-]/g, "_").replace(/\.{2,}/g, "_");
17392
17588
  return cleaned.slice(0, 100) || "run";
@@ -17401,16 +17597,24 @@ var ResumeStore = class {
17401
17597
  maxCheckpoints;
17402
17598
  maxAgeMs;
17403
17599
  now;
17600
+ claimTimeoutMs;
17404
17601
  constructor(options) {
17405
17602
  this.dir = options.dir;
17406
17603
  this.maxCheckpoints = options.maxCheckpoints ?? DEFAULT_MAX_CHECKPOINTS;
17407
17604
  this.maxAgeMs = options.maxAgeMs ?? DEFAULT_MAX_AGE_MS;
17408
17605
  this.now = options.now ?? Date.now;
17606
+ this.claimTimeoutMs = options.claimTimeoutMs ?? DEFAULT_CLAIM_TIMEOUT_MS;
17409
17607
  }
17410
17608
  /**
17411
- * Persist a checkpoint and prune. Never throws: failing to save a resume file
17412
- * must not turn a recoverable stop into a crash, which would be a strictly
17413
- * worse outcome than the one this store exists to improve.
17609
+ * Persist a checkpoint and prune.
17610
+ *
17611
+ * Never throws — failing to save a resume file must not turn a recoverable
17612
+ * stop into a crash — but DOES report whether the atomic rename landed.
17613
+ * Callers decide whether an original claim is safe to discard based on that
17614
+ * answer, so "I swallowed the error" and "the checkpoint is on disk" must not
17615
+ * look alike to them.
17616
+ *
17617
+ * @returns true only when the checkpoint is durably on disk.
17414
17618
  */
17415
17619
  async save(checkpoint) {
17416
17620
  try {
@@ -17426,7 +17630,9 @@ var ResumeStore = class {
17426
17630
  await promises.writeFile(tempPath, JSON.stringify(record, null, 2), "utf-8");
17427
17631
  await promises.rename(tempPath, finalPath);
17428
17632
  await this.prune();
17633
+ return true;
17429
17634
  } catch {
17635
+ return false;
17430
17636
  }
17431
17637
  }
17432
17638
  /** Valid, unexpired checkpoints, newest first. */
@@ -17456,15 +17662,87 @@ var ResumeStore = class {
17456
17662
  return (await this.list())[0] ?? null;
17457
17663
  }
17458
17664
  /**
17459
- * Load a checkpoint and delete it in the same step. Resuming twice from one
17460
- * checkpoint would re-run restored work, so consumption is part of reading.
17665
+ * Claim a checkpoint for a resume attempt.
17666
+ *
17667
+ * This used to DELETE the file and return it, which opened a window with no
17668
+ * recovery record at all: between preparing the continuation and the resumed
17669
+ * run writing its own checkpoint, a crash lost the only copy — and a crash is
17670
+ * precisely what this store exists to survive. Deleting on read optimised for
17671
+ * "never resume twice" at the cost of "never lose the work", which is the
17672
+ * wrong trade for a recovery mechanism.
17673
+ *
17674
+ * Instead the file is RENAMED to `.claimed`, which still hides it from
17675
+ * `list()` (so a concurrent resume cannot take the same work) but keeps it on
17676
+ * disk. The caller then either `release`s it — restoring it for another
17677
+ * attempt — or `settle`s it once the resumed run is safely under way. A claim
17678
+ * abandoned by a dead process is reclaimed after `claimTimeoutMs`.
17461
17679
  */
17462
- async consume(taskId) {
17680
+ async claim(taskId) {
17463
17681
  const all = await this.list();
17464
17682
  const target = taskId ? all.find((c) => c.taskId === taskId) : all[0];
17465
17683
  if (!target) return null;
17466
- await this.delete(target.taskId);
17467
- return target;
17684
+ try {
17685
+ const entries = await promises.readdir(this.dir);
17686
+ const wanted = safeId(target.taskId);
17687
+ const source = entries.find((name) => FILE_PATTERN.exec(name)?.[2] === wanted);
17688
+ if (!source) return null;
17689
+ const claimId = `${this.now()}-${safeId(target.taskId)}`;
17690
+ await promises.rename(path34.join(this.dir, source), path34.join(this.dir, `${claimId}${CLAIM_SUFFIX}`));
17691
+ return { checkpoint: target, claimId };
17692
+ } catch {
17693
+ return null;
17694
+ }
17695
+ }
17696
+ /** Give a claim back, so the work stays recoverable after a failed start. */
17697
+ async release(claimId) {
17698
+ try {
17699
+ await promises.rename(
17700
+ path34.join(this.dir, `${claimId}${CLAIM_SUFFIX}`),
17701
+ path34.join(this.dir, `${claimId}.json`)
17702
+ );
17703
+ } catch {
17704
+ }
17705
+ }
17706
+ /** Discard a claim for good — the resumed run has taken ownership. */
17707
+ async settle(claimId) {
17708
+ try {
17709
+ await promises.rm(path34.join(this.dir, `${claimId}${CLAIM_SUFFIX}`), { force: true });
17710
+ } catch {
17711
+ }
17712
+ }
17713
+ /**
17714
+ * Claim-and-discard in one step. Kept for callers that genuinely want the old
17715
+ * semantics; prefer claim/settle so a crash mid-resume stays recoverable.
17716
+ */
17717
+ async consume(taskId) {
17718
+ const claimed = await this.claim(taskId);
17719
+ if (!claimed) return null;
17720
+ await this.settle(claimed.claimId);
17721
+ return claimed.checkpoint;
17722
+ }
17723
+ /**
17724
+ * Return claims abandoned by a process that died mid-resume. Without this a
17725
+ * crash between claim and settle would strand the checkpoint invisibly —
17726
+ * trading one loss window for another.
17727
+ */
17728
+ async reclaimStale() {
17729
+ let reclaimed = 0;
17730
+ try {
17731
+ const entries = await promises.readdir(this.dir);
17732
+ const cutoff = this.now() - this.claimTimeoutMs;
17733
+ for (const name of entries) {
17734
+ if (!name.endsWith(CLAIM_SUFFIX)) continue;
17735
+ const stamp2 = Number(/^(\d+)-/.exec(name)?.[1]);
17736
+ if (!Number.isFinite(stamp2) || stamp2 >= cutoff) continue;
17737
+ await promises.rename(
17738
+ path34.join(this.dir, name),
17739
+ path34.join(this.dir, `${name.slice(0, -CLAIM_SUFFIX.length)}.json`)
17740
+ );
17741
+ reclaimed++;
17742
+ }
17743
+ } catch {
17744
+ }
17745
+ return reclaimed;
17468
17746
  }
17469
17747
  /** Remove every checkpoint for a task id. */
17470
17748
  async delete(taskId) {
@@ -18481,6 +18759,25 @@ var Cascade = class _Cascade extends EventEmitter {
18481
18759
  lastInterruptedRun;
18482
18760
  /** Durable resume checkpoints. See core/orchestration/resume-store.ts. */
18483
18761
  resumeStore;
18762
+ /** Claim held by an in-flight resume, settled once the run owns the work. */
18763
+ claimedResumeId;
18764
+ /**
18765
+ * Facts inherited from the checkpoint this run is resuming, so its own
18766
+ * checkpoint is cumulative rather than only covering this attempt.
18767
+ */
18768
+ resumedFrom;
18769
+ /** Whether THIS run persisted a checkpoint of its own. Drives settle-vs-release. */
18770
+ wroteCheckpointThisRun = false;
18771
+ /**
18772
+ * Whether the run reached the end of its normal path.
18773
+ *
18774
+ * NOT the same as `runError == null`: the cancellation and budget handlers
18775
+ * both null `runError` deliberately (an intentional stop is not a telemetry
18776
+ * error) and the breaker path never sets it at all. Treating that as success
18777
+ * meant an immediate cancel — no output, no replacement checkpoint — deleted
18778
+ * the original claim and the finished sections with it.
18779
+ */
18780
+ completedNormally = false;
18484
18781
  /** Section results of the CURRENT run, so an interruption can checkpoint them. */
18485
18782
  currentRunCompleted = [];
18486
18783
  initPromise;
@@ -18998,17 +19295,31 @@ ${input}` }],
18998
19295
  * worse. Nothing here is allowed to throw.
18999
19296
  */
19000
19297
  async checkpointRun(taskId, prompt, partialOutput, reason, detail) {
19001
- if (!this.currentRunCompleted.length && !partialOutput) return;
19298
+ const inherited = this.resumedFrom?.completed ?? [];
19299
+ const merged = /* @__PURE__ */ new Map();
19300
+ for (const node of inherited) merged.set(node.id, node);
19301
+ for (const node of this.currentRunCompleted) {
19302
+ const existing = merged.get(node.id);
19303
+ if (existing?.status === "COMPLETED" && node.status !== "COMPLETED") continue;
19304
+ merged.set(node.id, node);
19305
+ }
19306
+ const completed = [...merged.values()];
19307
+ const canonicalPrompt = this.resumedFrom?.prompt ?? prompt;
19308
+ const carriedPartial = partialOutput || (this.resumedFrom?.partialOutput ?? "");
19309
+ if (!completed.length && !carriedPartial) return false;
19002
19310
  try {
19003
- await this.resumeStore?.save({
19311
+ const saved = await this.resumeStore?.save({
19004
19312
  taskId,
19005
- prompt,
19313
+ prompt: canonicalPrompt,
19006
19314
  reason,
19007
19315
  ...detail ? { detail } : {},
19008
- partialOutput,
19009
- completed: [...this.currentRunCompleted]
19316
+ partialOutput: carriedPartial,
19317
+ completed
19010
19318
  });
19319
+ this.wroteCheckpointThisRun = saved === true;
19320
+ return saved === true;
19011
19321
  } catch {
19322
+ return false;
19012
19323
  }
19013
19324
  }
19014
19325
  /** True when a task can be resumed via /continue (this process or a previous one). */
@@ -19046,11 +19357,55 @@ ${input}` }],
19046
19357
  * checkpoint, so the same finished work is never restored into two runs.
19047
19358
  */
19048
19359
  async prepareDurableResume(opts = {}) {
19049
- const checkpoint = await this.resumeStore?.consume(opts.taskId);
19050
- if (!checkpoint) return null;
19051
- this.lastInterruptedRun = void 0;
19052
- this.raiseBudgetForResume(opts.maxTokens);
19053
- return this.buildResumePrompt(checkpoint.prompt, checkpoint.partialOutput, checkpoint.completed);
19360
+ await this.resumeStore?.reclaimStale();
19361
+ const claimed = await this.resumeStore?.claim(opts.taskId);
19362
+ if (!claimed) return null;
19363
+ try {
19364
+ this.lastInterruptedRun = void 0;
19365
+ this.raiseBudgetForResume(opts.maxTokens);
19366
+ const prompt = this.buildResumePrompt(
19367
+ claimed.checkpoint.prompt,
19368
+ claimed.checkpoint.partialOutput,
19369
+ claimed.checkpoint.completed
19370
+ );
19371
+ this.resumedFrom = {
19372
+ prompt: claimed.checkpoint.prompt,
19373
+ completed: claimed.checkpoint.completed,
19374
+ partialOutput: claimed.checkpoint.partialOutput
19375
+ };
19376
+ this.claimedResumeId = claimed.claimId;
19377
+ return prompt;
19378
+ } catch (err) {
19379
+ await this.resumeStore?.release(claimed.claimId);
19380
+ throw err;
19381
+ }
19382
+ }
19383
+ /**
19384
+ * Discard the claimed checkpoint once the resumed run has taken ownership of
19385
+ * the work (it has produced its own checkpoint, or finished). Called by the
19386
+ * run loop; safe to call when nothing is claimed.
19387
+ */
19388
+ async settleClaimedResume(succeeded) {
19389
+ const claimId = this.claimedResumeId;
19390
+ if (!claimId) return;
19391
+ this.claimedResumeId = void 0;
19392
+ if (succeeded || this.wroteCheckpointThisRun) {
19393
+ await this.resumeStore?.settle(claimId);
19394
+ } else {
19395
+ await this.resumeStore?.release(claimId);
19396
+ }
19397
+ }
19398
+ /**
19399
+ * Hand back a claim taken by prepareDurableResume() when the caller never
19400
+ * gets as far as run(). The REPL prepares the continuation and submits it
19401
+ * separately, so a failure in between would otherwise strand the checkpoint
19402
+ * for the whole lease timeout before reclaimStale() could recover it.
19403
+ */
19404
+ async releaseClaimedResume() {
19405
+ const claimId = this.claimedResumeId;
19406
+ if (!claimId) return;
19407
+ this.claimedResumeId = void 0;
19408
+ await this.resumeStore?.release(claimId);
19054
19409
  }
19055
19410
  /** A resume gets a bigger budget — the previous attempt exhausted the old one. */
19056
19411
  raiseBudgetForResume(maxTokens) {
@@ -19086,7 +19441,12 @@ ${partialOutput}` : ""
19086
19441
  async resumeRun(opts = {}) {
19087
19442
  const prompt = await this.prepareDurableResume(opts) ?? this.prepareResume(opts);
19088
19443
  if (!prompt) return null;
19089
- return this.run({ prompt });
19444
+ try {
19445
+ return await this.run({ prompt });
19446
+ } catch (err) {
19447
+ await this.releaseClaimedResume();
19448
+ throw err;
19449
+ }
19090
19450
  }
19091
19451
  getWorkspacePath() {
19092
19452
  return this.workspacePath;
@@ -19456,6 +19816,9 @@ ${prompt}` : prompt;
19456
19816
  const taskId = randomUUID();
19457
19817
  this.decisionLog = [];
19458
19818
  this.currentRunCompleted = [];
19819
+ this.wroteCheckpointThisRun = false;
19820
+ this.completedNormally = false;
19821
+ if (!this.claimedResumeId) this.resumedFrom = void 0;
19459
19822
  if (options.fastAnswer) {
19460
19823
  return this.runFastAnswer(options, startMs, taskId);
19461
19824
  }
@@ -19734,6 +20097,7 @@ _(incomplete: ${reasons.join("; ")})_` : partial;
19734
20097
  ${finalOutput}` : ""
19735
20098
  ].join("\n").trimEnd();
19736
20099
  }
20100
+ this.completedNormally = trip == null;
19737
20101
  } catch (err) {
19738
20102
  if (err instanceof CascadeCancelledError || err instanceof Error && err.name === "AbortError" || options.signal?.aborted) {
19739
20103
  this.emit("run:cancelled", {
@@ -19775,6 +20139,10 @@ ${finalOutput}` : ""
19775
20139
  escalator.cancelAllPending();
19776
20140
  } catch {
19777
20141
  }
20142
+ try {
20143
+ await this.settleClaimedResume(this.completedNormally);
20144
+ } catch {
20145
+ }
19778
20146
  try {
19779
20147
  this.releasePendingEscalations();
19780
20148
  } catch {
@@ -20965,7 +21333,8 @@ function AgentTreeInternal({
20965
21333
  ] }),
20966
21334
  node.status === "COMPLETED" && /* @__PURE__ */ jsx(Text, { color: theme.colors.success, children: " \u2714" }),
20967
21335
  node.status === "FAILED" && /* @__PURE__ */ jsx(Text, { color: theme.colors.error, children: " \u2718" }),
20968
- node.status === "ESCALATED" && /* @__PURE__ */ jsx(Text, { color: theme.colors.warning, children: " \u25B2" })
21336
+ node.status === "ESCALATED" && /* @__PURE__ */ jsx(Text, { color: theme.colors.warning, children: " \u25B2" }),
21337
+ node.status === "BLOCKED" && /* @__PURE__ */ jsx(Text, { color: theme.colors.muted, children: " \u292B skipped" })
20969
21338
  ] }, `t2-${node.id}-${i}`);
20970
21339
  }
20971
21340
  if (row.type === "t3") {
@@ -20990,7 +21359,8 @@ function AgentTreeInternal({
20990
21359
  /* @__PURE__ */ jsx(Spinner, { type: "dots" })
20991
21360
  ] }),
20992
21361
  node.status === "COMPLETED" && /* @__PURE__ */ jsx(Text, { color: theme.colors.success, children: " \u2714" }),
20993
- node.status === "FAILED" && /* @__PURE__ */ jsx(Text, { color: theme.colors.error, children: " \u2718" })
21362
+ node.status === "FAILED" && /* @__PURE__ */ jsx(Text, { color: theme.colors.error, children: " \u2718" }),
21363
+ node.status === "BLOCKED" && /* @__PURE__ */ jsx(Text, { color: theme.colors.muted, children: " \u292B skipped" })
20994
21364
  ] }, `t3-${node.id}-${i}`);
20995
21365
  }
20996
21366
  return null;
@@ -21989,7 +22359,7 @@ function Repl({ config, workspacePath, themeName, initialPrompt, identityName, a
21989
22359
  }, []);
21990
22360
  const recordNodeEvent = useCallback((event) => {
21991
22361
  const existing = treeNodesRef.current.get(event.tierId);
21992
- const node = { id: event.tierId, role: event.role ?? existing?.role ?? "T3", label: event.label ?? existing?.label ?? event.tierId, status: event.status ?? existing?.status ?? "IDLE", currentAction: event.currentAction ?? existing?.currentAction, progressPct: event.progressPct ?? existing?.progressPct, parentId: event.parentId ?? existing?.parentId, children: [] };
22362
+ const node = { id: event.tierId, role: event.role ?? existing?.role ?? "T3", label: event.label ?? existing?.label ?? event.tierId, status: event.status ?? existing?.status ?? "IDLE", currentAction: event.currentAction ?? existing?.currentAction, progressPct: event.progressPct ?? existing?.progressPct, parentId: event.parentId ?? existing?.parentId, nodeId: event.nodeId ?? existing?.nodeId, dependsOn: event.dependsOn ?? existing?.dependsOn, waveId: event.waveId ?? existing?.waveId, blockedBy: event.blockedBy ?? existing?.blockedBy, children: [] };
21993
22363
  treeNodesRef.current.set(event.tierId, node);
21994
22364
  const store = storeRef.current;
21995
22365
  if (store) {
@@ -22326,13 +22696,21 @@ ${lastUser.content}`;
22326
22696
  onContinue: async (args) => {
22327
22697
  const cascade = cascadeRef.current;
22328
22698
  if (!cascade) return "Not ready yet.";
22699
+ if (state.isExecuting) {
22700
+ return "A task is already running \u2014 wait for it to finish (or press Esc to stop it) before using /continue.";
22701
+ }
22329
22702
  const n = args[0] ? parseInt(args[0].replace(/[^0-9]/g, ""), 10) : NaN;
22330
22703
  const opts = Number.isFinite(n) && n > 0 ? { maxTokens: n } : {};
22331
22704
  const prompt = await cascade.prepareDurableResume(opts) ?? cascade.prepareResume(opts);
22332
22705
  if (!prompt) {
22333
22706
  return "Nothing to continue \u2014 no interrupted task was found for this workspace.";
22334
22707
  }
22335
- await handleSubmit(prompt);
22708
+ try {
22709
+ await handleSubmit(prompt);
22710
+ } catch (err) {
22711
+ await cascade.releaseClaimedResume();
22712
+ throw err;
22713
+ }
22336
22714
  return "";
22337
22715
  }
22338
22716
  });
@@ -24825,8 +25203,14 @@ var DashboardSocket = class {
24825
25203
  emitCascadeEvent(ev) {
24826
25204
  this.io.emit("cascade:event", ev);
24827
25205
  }
24828
- emitTierStatus(tierId, role, status, sessionId, action) {
24829
- const payload = { tierId, role, status, action, timestamp: (/* @__PURE__ */ new Date()).toISOString(), sessionId };
25206
+ /**
25207
+ * `extra` exists because this signature is positional: every field the tier
25208
+ * emits that is not named here is silently dropped on this path, while the
25209
+ * other two forwarders spread the whole event through. That asymmetry is how
25210
+ * a field can reach the web dashboard from one code path and not another.
25211
+ */
25212
+ emitTierStatus(tierId, role, status, sessionId, action, extra) {
25213
+ const payload = { tierId, role, status, action, timestamp: (/* @__PURE__ */ new Date()).toISOString(), sessionId, ...extra };
24830
25214
  this.io.emit("tier:status", payload);
24831
25215
  this.io.to(`session:${sessionId}`).emit("tier:status", payload);
24832
25216
  }