@cabane/companion 0.6.111 → 0.6.113

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.
Files changed (3) hide show
  1. package/dist/cli.js +222 -50
  2. package/dist/runtime.js +222 -50
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -2901,9 +2901,27 @@ function describeErrorBody(body) {
2901
2901
  if (text.length === 0) return void 0;
2902
2902
  return text.length > ERROR_BODY_LOG_CAP ? `${text.slice(0, ERROR_BODY_LOG_CAP)}\u2026` : text;
2903
2903
  }
2904
+ var CAUSE_TEXT_CAP = 200;
2905
+ function describeError(err) {
2906
+ const message = err instanceof Error ? err.message : String(err);
2907
+ const cause = describeCause(err);
2908
+ return cause === void 0 ? message : `${message}: ${cause}`;
2909
+ }
2910
+ function describeCause(err) {
2911
+ if (!(err instanceof Error)) return void 0;
2912
+ const cause = err.cause;
2913
+ if (cause === void 0 || cause === null) return void 0;
2914
+ if (typeof cause === "object") {
2915
+ const code = cause.code;
2916
+ if (typeof code === "string" && code.length > 0) return code;
2917
+ }
2918
+ const text = cause instanceof Error ? cause.message : typeof cause === "string" ? cause : void 0;
2919
+ if (text === void 0 || text.length === 0) return void 0;
2920
+ return text.length > CAUSE_TEXT_CAP ? `${text.slice(0, CAUSE_TEXT_CAP)}\u2026` : text;
2921
+ }
2904
2922
  function apiErrorLogFields(err) {
2905
2923
  const fields = {
2906
- err: err instanceof Error ? err.message : String(err)
2924
+ err: describeError(err)
2907
2925
  };
2908
2926
  if (err instanceof ApiError) {
2909
2927
  fields.status = err.status;
@@ -2922,6 +2940,7 @@ import { randomUUID as randomUUID4 } from "crypto";
2922
2940
 
2923
2941
  // src/api.ts
2924
2942
  var RETRY_BACKOFF_MS = [250, 750];
2943
+ var TURN_CONTEXT_BACKOFF_MS = [250, 750, 2e3, 5e3];
2925
2944
  var ACTIVE_RUN_OUTBOX_SEQ = 0;
2926
2945
  var LEASE_CHECK_TIMEOUT_MS = 1e4;
2927
2946
  function activeRunOutboxKey(conversationId, agentId) {
@@ -2972,14 +2991,26 @@ var CabaneApi = class {
2972
2991
  return parsed;
2973
2992
  }
2974
2993
  async request(method, path, body, opts = {}) {
2975
- const { signal, retry = false } = opts;
2976
- const maxAttempts = retry ? RETRY_BACKOFF_MS.length + 1 : 1;
2994
+ const { signal, retry = method === "GET", backoffMs = RETRY_BACKOFF_MS } = opts;
2995
+ const maxAttempts = retry ? backoffMs.length + 1 : 1;
2977
2996
  for (let attempt = 1; ; attempt++) {
2978
2997
  try {
2979
2998
  return await this.attempt(method, path, body, signal);
2980
2999
  } catch (err) {
2981
3000
  if (attempt >= maxAttempts || signal?.aborted || !isRetryable(err)) throw err;
2982
- await sleep2(RETRY_BACKOFF_MS[attempt - 1], signal);
3001
+ const delayMs = backoffMs[attempt - 1];
3002
+ this.opts.log?.info(
3003
+ {
3004
+ method,
3005
+ path: pathForLog(path),
3006
+ attempt,
3007
+ maxAttempts,
3008
+ delayMs,
3009
+ err: describeError(err)
3010
+ },
3011
+ "companion: API call failed transiently; retrying"
3012
+ );
3013
+ await sleep2(delayMs, signal);
2983
3014
  if (signal?.aborted) throw err;
2984
3015
  }
2985
3016
  }
@@ -3117,9 +3148,16 @@ var CabaneApi = class {
3117
3148
  // CT1543: `episode` is the dispatch event's own, echoed back so the server can
3118
3149
  // tell this frame from a later wake on the same message. Omitted when the
3119
3150
  // event didn't carry one, which is the only thing an older event can mean.
3120
- getTurnContext(conversationId, messageId2, turnId, resumed, fresh, episode) {
3151
+ // CT1657: `signal` is the turn's own abort. It matters here more than on any
3152
+ // other read, because this call now waits up to ~8s across five attempts: a
3153
+ // Stop landing in that window used to leave the whole schedule running, and
3154
+ // the person watched a turn they had already stopped keep trying.
3155
+ getTurnContext(conversationId, messageId2, turnId, resumed, fresh, episode, signal) {
3121
3156
  const q = `conversationId=${encodeURIComponent(conversationId)}&messageId=${encodeURIComponent(messageId2)}` + (turnId ? `&turnId=${encodeURIComponent(turnId)}` : "") + (resumed ? "&resumed=1" : "") + (fresh ? "&fresh=1" : "") + (episode !== void 0 ? `&episode=${encodeURIComponent(String(episode))}` : "");
3122
- return this.request("GET", `/api/agent/turn-context?${q}`);
3157
+ return this.request("GET", `/api/agent/turn-context?${q}`, void 0, {
3158
+ backoffMs: this.opts.turnContextBackoffMs ?? TURN_CONTEXT_BACKOFF_MS,
3159
+ ...signal ? { signal } : {}
3160
+ });
3123
3161
  }
3124
3162
  // CT1380: the local half of a resumed turn's seq floor (see
3125
3163
  // `Outbox.maxSeqForTurn`). 0 when no outbox is configured — a one-shot CLI or
@@ -3204,6 +3242,12 @@ var CabaneApi = class {
3204
3242
  `/api/workspaces/${workspaceId}/conversations/${conversationId}/participants/agents/${agentId}/turn-lease?${q}`,
3205
3243
  void 0,
3206
3244
  {
3245
+ // CT1657: the one GET that opts OUT of the default retry. The reasoning
3246
+ // is right above — a failed probe has no answer worth replaying and the
3247
+ // next tick asks again in seconds, so retrying inside this one would
3248
+ // stretch a tick over its successor's slot, which is what the deadline
3249
+ // exists to prevent.
3250
+ retry: false,
3207
3251
  signal: AbortSignal.timeout(this.opts.leaseCheckTimeoutMs ?? LEASE_CHECK_TIMEOUT_MS)
3208
3252
  }
3209
3253
  );
@@ -3395,6 +3439,10 @@ function isRetryable(err) {
3395
3439
  function isAbortError(err) {
3396
3440
  return err instanceof Error && err.name === "AbortError";
3397
3441
  }
3442
+ function pathForLog(path) {
3443
+ const q = path.indexOf("?");
3444
+ return q === -1 ? path : path.slice(0, q);
3445
+ }
3398
3446
  function sleep2(ms, signal) {
3399
3447
  return new Promise((resolve2) => {
3400
3448
  if (signal?.aborted) return resolve2();
@@ -9569,6 +9617,7 @@ var TurnExecution = class {
9569
9617
  // identity-checked at the supervisor — then clear the active-run flag);
9570
9618
  // everything past the lease settles in `execute()`'s one `finally`.
9571
9619
  async run() {
9620
+ this.supervisor.registerAbort(this.turnId, this.abortController);
9572
9621
  try {
9573
9622
  await this.fetchContext();
9574
9623
  this.gateTrigger();
@@ -9590,6 +9639,8 @@ var TurnExecution = class {
9590
9639
  this.supervisor.releaseAbort(this.turnId, this.abortController);
9591
9640
  const msg = err instanceof Error ? err.message : String(err);
9592
9641
  await this.concludeAdmittedRun("setup_failed", `setup_failed: ${msg}`);
9642
+ } else {
9643
+ this.supervisor.releaseAbort(this.turnId, this.abortController);
9593
9644
  }
9594
9645
  throw err;
9595
9646
  }
@@ -9671,9 +9722,14 @@ var TurnExecution = class {
9671
9722
  turnId,
9672
9723
  this.resumed,
9673
9724
  void 0,
9674
- payload.episode
9725
+ payload.episode,
9726
+ this.abortController.signal
9675
9727
  );
9676
9728
  } catch (err) {
9729
+ if (this.abortController.signal.aborted) {
9730
+ turnLog.debug("dispatcher: stopped while loading the turn context");
9731
+ throw this.concluded("cancelled");
9732
+ }
9677
9733
  const status2 = err instanceof ApiError ? err.status : 0;
9678
9734
  if (status2 === 404) {
9679
9735
  turnLog.debug(
@@ -9688,9 +9744,8 @@ var TurnExecution = class {
9688
9744
  );
9689
9745
  throw this.concluded("turn_context_not_admitted");
9690
9746
  }
9691
- const reason = err instanceof Error ? err.message : String(err);
9692
- turnLog.debug({ err: reason }, "dispatcher: failed to fetch turn context");
9693
- const fetchReason = `fetch_failed: ${reason}`;
9747
+ turnLog.warn(apiErrorLogFields(err), "dispatcher: failed to fetch turn context");
9748
+ const fetchReason = `fetch_failed: ${describeError(err)}`;
9694
9749
  throw this.concluded(fetchReason, fetchReason);
9695
9750
  }
9696
9751
  this.turnContext = turnContext;
@@ -10053,7 +10108,12 @@ var TurnExecution = class {
10053
10108
  payload.messageId,
10054
10109
  turnId,
10055
10110
  false,
10056
- true
10111
+ true,
10112
+ void 0,
10113
+ // CT1657: the turn's abort. This recompose happens mid-run with the
10114
+ // adapter waiting on it, so a Stop here must end the wait rather than
10115
+ // hold the turn open for the rest of the retry schedule.
10116
+ this.abortController.signal
10057
10117
  );
10058
10118
  turnLog.debug(
10059
10119
  { reason },
@@ -10065,6 +10125,10 @@ var TurnExecution = class {
10065
10125
  content: context.content
10066
10126
  };
10067
10127
  } catch (err) {
10128
+ if (this.abortController.signal.aborted) {
10129
+ turnLog.debug({ reason }, "dispatcher: stopped while recomposing the turn context");
10130
+ return null;
10131
+ }
10068
10132
  turnLog.error(
10069
10133
  { reason, ...apiErrorLogFields(err) },
10070
10134
  "Couldn't load the full conversation; continuing with the recent messages."
@@ -10590,6 +10654,41 @@ async function enumerateOpencodeModels(serverUrl, fetchImpl = fetch) {
10590
10654
  }
10591
10655
  }
10592
10656
 
10657
+ // src/dispatch-claims.ts
10658
+ var DispatchClaims = class {
10659
+ inFlight = /* @__PURE__ */ new Map();
10660
+ releases = /* @__PURE__ */ new Map();
10661
+ // Claim a dispatch id for this process. Synchronous by design: two copies can
10662
+ // arrive in the same tick, and a claim taken after an `await` would let both
10663
+ // through.
10664
+ claim(dispatchId) {
10665
+ const running = this.inFlight.get(dispatchId);
10666
+ if (running) return { first: false, done: running };
10667
+ let release;
10668
+ this.inFlight.set(
10669
+ dispatchId,
10670
+ new Promise((resolve2) => {
10671
+ release = resolve2;
10672
+ })
10673
+ );
10674
+ this.releases.set(dispatchId, release);
10675
+ return { first: true };
10676
+ }
10677
+ // Hand the claim back once the run has finished — whatever its outcome.
10678
+ // Releasing an id that isn't claimed is a no-op.
10679
+ release(dispatchId) {
10680
+ const resolve2 = this.releases.get(dispatchId);
10681
+ if (!resolve2) return;
10682
+ this.releases.delete(dispatchId);
10683
+ this.inFlight.delete(dispatchId);
10684
+ resolve2();
10685
+ }
10686
+ // The live set's size — the concurrent-turn count.
10687
+ get size() {
10688
+ return this.inFlight.size;
10689
+ }
10690
+ };
10691
+
10593
10692
  // src/outbox.ts
10594
10693
  import {
10595
10694
  existsSync as existsSync12,
@@ -10936,6 +11035,13 @@ var DRAIN_BASE_MS = 1e3;
10936
11035
  var DRAIN_MAX_MS = 3e4;
10937
11036
  var DRAIN_IDLE_MS = 15e3;
10938
11037
  var DEFAULT_UNRUNNABLE_RETRY_DELAYS_MS = [0, 1e3, 3e3];
11038
+ function dispatchPayload(wire) {
11039
+ if (wire.type !== "device:dispatch_requested") return null;
11040
+ return {
11041
+ type: "device:dispatch_requested",
11042
+ ...wire.payload
11043
+ };
11044
+ }
10939
11045
  var CompanionSupervisor = class {
10940
11046
  workspaces = /* @__PURE__ */ new Map();
10941
11047
  config;
@@ -10994,6 +11100,9 @@ var CompanionSupervisor = class {
10994
11100
  // turn's classified outcome here (per runtime); the heartbeat echoes it so the
10995
11101
  // server's per-connector quota state survives a restart/race.
10996
11102
  connectorHealth = new ConnectorHealthStore();
11103
+ // ACC10: which dispatches this process is running, so the copy of a frame that
11104
+ // arrives on the other stream doesn't run the turn a second time.
11105
+ claims = new DispatchClaims();
10997
11106
  constructor(opts) {
10998
11107
  this.config = opts.config;
10999
11108
  this.log = opts.log;
@@ -11037,7 +11146,9 @@ var CompanionSupervisor = class {
11037
11146
  if (ev.event === "assignments_changed") {
11038
11147
  if (this.refreshing && this.inFlightRefresh) await this.inFlightRefresh;
11039
11148
  await this.refreshAssignments();
11149
+ return;
11040
11150
  }
11151
+ if (ev.event === "message") await this.handleDeviceMessage(ev);
11041
11152
  },
11042
11153
  onAuthFailure: () => {
11043
11154
  this.log.error(
@@ -11548,17 +11659,75 @@ var CompanionSupervisor = class {
11548
11659
  async handleWorkspaceMessage(wr, ev) {
11549
11660
  if (ev.event !== "message") return;
11550
11661
  this.hub.recordEvent(wr.workspaceId);
11551
- let wire;
11662
+ const wire = this.parseFrame(ev);
11663
+ if (!wire) return;
11664
+ if (ev.id) wr.cursor.observe(ev.id);
11665
+ await this.deliverFrame(wr, ev.id, wire);
11666
+ }
11667
+ // ACC10: the device stream's frames — dispatch and cancel, addressed to this
11668
+ // device and authenticated as it. Same frames and the same routing as above;
11669
+ // what differs is that this stream keeps no cursor (it replays what is still
11670
+ // offered on every connect instead of resuming from a `Last-Event-ID`), so
11671
+ // nothing here observes or settles one.
11672
+ async handleDeviceMessage(ev) {
11673
+ const wire = this.parseFrame(ev);
11674
+ if (!wire) return;
11675
+ let wr = this.workspaces.get(wire.workspaceId);
11676
+ if (!wr) {
11677
+ if (this.refreshing && this.inFlightRefresh) await this.inFlightRefresh;
11678
+ await this.refreshAssignments();
11679
+ wr = this.workspaces.get(wire.workspaceId);
11680
+ if (!wr) {
11681
+ const payload = dispatchPayload(wire);
11682
+ if (payload && payload.deviceId === this.deviceId) {
11683
+ await this.reportUnrunnableDispatch(wire.workspaceId, payload);
11684
+ } else {
11685
+ this.log.debug(
11686
+ { workspaceId: wire.workspaceId, type: wire.type },
11687
+ "companion: device frame for a workspace this device does not run; ignoring"
11688
+ );
11689
+ }
11690
+ return;
11691
+ }
11692
+ }
11693
+ this.hub.recordEvent(wr.workspaceId);
11694
+ await this.deliverFrame(wr, "", wire);
11695
+ }
11696
+ parseFrame(ev) {
11552
11697
  try {
11553
- wire = JSON.parse(ev.data);
11698
+ return JSON.parse(ev.data);
11554
11699
  } catch (err) {
11555
11700
  this.log.debug(
11556
11701
  { err: err instanceof Error ? err.message : String(err) },
11557
11702
  "malformed SSE payload"
11558
11703
  );
11704
+ return null;
11705
+ }
11706
+ }
11707
+ // ACC10: the dedupe gate, and the only place that exists because there are two
11708
+ // streams. Both carry the same frame with the same dispatch id; the first copy
11709
+ // to arrive runs the turn, and the second waits for it and then settles its own
11710
+ // stream's cursor. Settling a duplicate earlier than that would advance the
11711
+ // low-water mark past an in-flight turn, which is exactly the CT282 bug.
11712
+ //
11713
+ // A cancel needs no gate: `dispatcher.cancel` on an already-aborted run is a
11714
+ // no-op that reports `false`, so a second copy is inert.
11715
+ async deliverFrame(wr, cursorId, wire) {
11716
+ const claim = wire.type === "device:dispatch_requested" && wire.id ? this.claims.claim(wire.id) : null;
11717
+ if (claim && !claim.first) {
11718
+ await claim.done;
11719
+ if (cursorId) wr.cursor.settle(cursorId);
11559
11720
  return;
11560
11721
  }
11561
- if (ev.id) wr.cursor.observe(ev.id);
11722
+ try {
11723
+ await this.routeFrame(wr, cursorId, wire);
11724
+ } finally {
11725
+ if (claim) this.claims.release(wire.id);
11726
+ }
11727
+ }
11728
+ // Everything below here is the routing as it was: it takes a frame and knows
11729
+ // nothing about which stream carried it.
11730
+ async routeFrame(wr, cursorId, wire) {
11562
11731
  if (wire.type === "device:cancel_requested") {
11563
11732
  const payload2 = {
11564
11733
  type: "device:cancel_requested",
@@ -11578,17 +11747,14 @@ var CompanionSupervisor = class {
11578
11747
  );
11579
11748
  }
11580
11749
  }
11581
- if (ev.id) wr.cursor.settle(ev.id);
11750
+ if (cursorId) wr.cursor.settle(cursorId);
11582
11751
  return;
11583
11752
  }
11584
- if (wire.type !== "device:dispatch_requested") {
11585
- if (ev.id) wr.cursor.settle(ev.id);
11753
+ const payload = dispatchPayload(wire);
11754
+ if (!payload) {
11755
+ if (cursorId) wr.cursor.settle(cursorId);
11586
11756
  return;
11587
11757
  }
11588
- const payload = {
11589
- type: "device:dispatch_requested",
11590
- ...wire.payload
11591
- };
11592
11758
  if (this.deviceId && payload.deviceId && payload.deviceId !== this.deviceId) {
11593
11759
  this.log.debug(
11594
11760
  {
@@ -11599,7 +11765,7 @@ var CompanionSupervisor = class {
11599
11765
  },
11600
11766
  "companion: dispatch addressed to another device; ignoring"
11601
11767
  );
11602
- if (ev.id) wr.cursor.settle(ev.id);
11768
+ if (cursorId) wr.cursor.settle(cursorId);
11603
11769
  return;
11604
11770
  }
11605
11771
  let agent = wr.agents.get(payload.agentId);
@@ -11607,16 +11773,16 @@ var CompanionSupervisor = class {
11607
11773
  agent = await this.recoverRacedAgent(wr, payload);
11608
11774
  if (!agent) {
11609
11775
  if (this.deviceId && payload.deviceId === this.deviceId) {
11610
- const reported = await this.reportUnrunnableDispatch(wr, payload);
11776
+ const reported = await this.reportUnrunnableDispatch(wr.workspaceId, payload);
11611
11777
  if (!reported) return;
11612
11778
  }
11613
- if (ev.id) wr.cursor.settle(ev.id);
11779
+ if (cursorId) wr.cursor.settle(cursorId);
11614
11780
  return;
11615
11781
  }
11616
11782
  }
11617
11783
  const chainKey = `${payload.conversationId}|${payload.agentId}`;
11618
11784
  const prev = wr.chains.get(chainKey) ?? Promise.resolve();
11619
- const tail = prev.then(() => this.runDispatch(wr, ev, payload, agent)).catch((err) => {
11785
+ const tail = prev.then(() => this.runDispatch(wr, { dispatchId: wire.id, cursorId }, payload, agent)).catch((err) => {
11620
11786
  this.log.warn(
11621
11787
  {
11622
11788
  workspaceId: wr.workspaceId,
@@ -11643,7 +11809,7 @@ var CompanionSupervisor = class {
11643
11809
  // dropped socket) and one more attempt a second later usually lands. Bounded
11644
11810
  // hard — this runs inline on the event loop, and an unbounded wait here would
11645
11811
  // wedge the workspace's whole stream.
11646
- async reportUnrunnableDispatch(wr, payload) {
11812
+ async reportUnrunnableDispatch(workspaceId, payload) {
11647
11813
  if (!this.deviceApi) return false;
11648
11814
  let lastErr;
11649
11815
  for (const wait of this.unrunnableRetryDelaysMs) {
@@ -11651,7 +11817,7 @@ var CompanionSupervisor = class {
11651
11817
  if (wait > 0) await new Promise((r) => setTimeout(r, wait));
11652
11818
  try {
11653
11819
  const { cleared } = await this.deviceApi.reportDispatchDrop({
11654
- workspaceId: wr.workspaceId,
11820
+ workspaceId,
11655
11821
  conversationId: payload.conversationId,
11656
11822
  agentId: payload.agentId,
11657
11823
  messageId: payload.messageId,
@@ -11659,7 +11825,7 @@ var CompanionSupervisor = class {
11659
11825
  });
11660
11826
  this.log.debug(
11661
11827
  {
11662
- workspaceId: wr.workspaceId,
11828
+ workspaceId,
11663
11829
  conversationId: payload.conversationId,
11664
11830
  agentId: payload.agentId,
11665
11831
  cleared
@@ -11673,7 +11839,7 @@ var CompanionSupervisor = class {
11673
11839
  }
11674
11840
  this.log.error(
11675
11841
  {
11676
- workspaceId: wr.workspaceId,
11842
+ workspaceId,
11677
11843
  conversationId: payload.conversationId,
11678
11844
  agentId: payload.agentId,
11679
11845
  err: lastErr instanceof Error ? lastErr.message : String(lastErr)
@@ -11728,7 +11894,10 @@ var CompanionSupervisor = class {
11728
11894
  };
11729
11895
  const tail = this.runDispatch(
11730
11896
  wr,
11731
- { id: "", event: "recovery", data: "" },
11897
+ // No frame delivered this one — it came off `/runs` — so there is
11898
+ // neither a dispatch id to dedupe on nor a cursor to settle. The
11899
+ // `recoveredTurns` set above is this path's own guard.
11900
+ { dispatchId: "", cursorId: "" },
11732
11901
  payload,
11733
11902
  agent,
11734
11903
  run.turnId
@@ -11752,56 +11921,59 @@ var CompanionSupervisor = class {
11752
11921
  // One turn: the SJ516 F7 / CT26 skip-vs-resume dedupe, the dispatch, then
11753
11922
  // cursor + console summary. Mirrors the old per-workspace `handleEvent`, now
11754
11923
  // scoped to one agent.
11755
- async runDispatch(wr, ev, payload, agent, recoveredTurnId) {
11924
+ async runDispatch(wr, ids, payload, agent, recoveredTurnId) {
11756
11925
  const workspaceId = wr.workspaceId;
11757
- if (ev.id && hasDispatched(workspaceId, ev.id)) {
11758
- if (hasCompleted(workspaceId, ev.id)) {
11926
+ const settleCursor = () => {
11927
+ if (ids.cursorId) wr.cursor.settle(ids.cursorId);
11928
+ };
11929
+ if (ids.dispatchId && hasDispatched(workspaceId, ids.dispatchId)) {
11930
+ if (hasCompleted(workspaceId, ids.dispatchId)) {
11759
11931
  this.log.debug(
11760
- { workspaceId, eventId: ev.id },
11932
+ { workspaceId, eventId: ids.dispatchId },
11761
11933
  "companion: skipping already-completed event (resume after restart)"
11762
11934
  );
11763
- wr.cursor.settle(ev.id);
11935
+ settleCursor();
11764
11936
  return;
11765
11937
  }
11766
11938
  if (noResume()) {
11767
11939
  this.log.debug(
11768
- { workspaceId, eventId: ev.id },
11940
+ { workspaceId, eventId: ids.dispatchId },
11769
11941
  "companion: skipping interrupted turn (CABANE_COMPANION_NO_RESUME=1) \u2014 resume disabled for this boot"
11770
11942
  );
11771
- markCompleted(workspaceId, ev.id);
11772
- wr.cursor.settle(ev.id);
11943
+ markCompleted(workspaceId, ids.dispatchId);
11944
+ settleCursor();
11773
11945
  return;
11774
11946
  }
11775
- const attempt = bumpResumeAttempt(workspaceId, ev.id);
11947
+ const attempt = bumpResumeAttempt(workspaceId, ids.dispatchId);
11776
11948
  if (attempt > MAX_RESUME_ATTEMPTS) {
11777
11949
  this.log.error(
11778
- { workspaceId, eventId: ev.id, attempt, cap: MAX_RESUME_ATTEMPTS },
11950
+ { workspaceId, eventId: ids.dispatchId, attempt, cap: MAX_RESUME_ATTEMPTS },
11779
11951
  "An interrupted turn couldn't finish after several attempts. Reply in Cabane to try again."
11780
11952
  );
11781
- markCompleted(workspaceId, ev.id);
11782
- wr.cursor.settle(ev.id);
11953
+ markCompleted(workspaceId, ids.dispatchId);
11954
+ settleCursor();
11783
11955
  return;
11784
11956
  }
11785
11957
  this.log.debug(
11786
- { workspaceId, eventId: ev.id, attempt, cap: MAX_RESUME_ATTEMPTS },
11958
+ { workspaceId, eventId: ids.dispatchId, attempt, cap: MAX_RESUME_ATTEMPTS },
11787
11959
  "companion: re-dispatching interrupted turn (resume after restart)"
11788
11960
  );
11789
11961
  }
11790
- const resumedTurnId = recoveredTurnId ?? (ev.id ? turnIdForEvent(workspaceId, ev.id) : null);
11962
+ const resumedTurnId = recoveredTurnId ?? (ids.dispatchId ? turnIdForEvent(workspaceId, ids.dispatchId) : null);
11791
11963
  const turnId = resumedTurnId ?? randomUUID4();
11792
- if (ev.id) {
11793
- const liveTurnIds = rememberTurnId(workspaceId, ev.id, turnId);
11964
+ if (ids.dispatchId) {
11965
+ const liveTurnIds = rememberTurnId(workspaceId, ids.dispatchId, turnId);
11794
11966
  if (liveTurnIds > TURN_ID_OVERFLOW_WARN) {
11795
11967
  this.log.debug(
11796
11968
  { workspaceId, liveTurnIds, threshold: TURN_ID_OVERFLOW_WARN },
11797
11969
  "companion: in-flight turn-id map is implausibly large \u2014 completion pruning is likely broken. Keeping every mapping; dropping one would silently lose a resumable turn."
11798
11970
  );
11799
11971
  }
11800
- markDispatched(workspaceId, ev.id);
11972
+ markDispatched(workspaceId, ids.dispatchId);
11801
11973
  }
11802
11974
  if (resumedTurnId) {
11803
11975
  this.log.debug(
11804
- { workspaceId, eventId: ev.id, turnId },
11976
+ { workspaceId, eventId: ids.dispatchId, turnId },
11805
11977
  "companion: resuming an interrupted turn under its original id"
11806
11978
  );
11807
11979
  }
@@ -11816,8 +11988,8 @@ var CompanionSupervisor = class {
11816
11988
  turnId,
11817
11989
  resumed: resumedTurnId !== null
11818
11990
  });
11819
- if (ev.id) markCompleted(workspaceId, ev.id);
11820
- if (ev.id) wr.cursor.settle(ev.id);
11991
+ if (ids.dispatchId) markCompleted(workspaceId, ids.dispatchId);
11992
+ settleCursor();
11821
11993
  const durationS = (result.durationMs / 1e3).toFixed(1);
11822
11994
  const bindings = {
11823
11995
  ...turnBindings,
package/dist/runtime.js CHANGED
@@ -2235,9 +2235,27 @@ function describeErrorBody(body) {
2235
2235
  if (text.length === 0) return void 0;
2236
2236
  return text.length > ERROR_BODY_LOG_CAP ? `${text.slice(0, ERROR_BODY_LOG_CAP)}\u2026` : text;
2237
2237
  }
2238
+ var CAUSE_TEXT_CAP = 200;
2239
+ function describeError(err) {
2240
+ const message = err instanceof Error ? err.message : String(err);
2241
+ const cause = describeCause(err);
2242
+ return cause === void 0 ? message : `${message}: ${cause}`;
2243
+ }
2244
+ function describeCause(err) {
2245
+ if (!(err instanceof Error)) return void 0;
2246
+ const cause = err.cause;
2247
+ if (cause === void 0 || cause === null) return void 0;
2248
+ if (typeof cause === "object") {
2249
+ const code = cause.code;
2250
+ if (typeof code === "string" && code.length > 0) return code;
2251
+ }
2252
+ const text = cause instanceof Error ? cause.message : typeof cause === "string" ? cause : void 0;
2253
+ if (text === void 0 || text.length === 0) return void 0;
2254
+ return text.length > CAUSE_TEXT_CAP ? `${text.slice(0, CAUSE_TEXT_CAP)}\u2026` : text;
2255
+ }
2238
2256
  function apiErrorLogFields(err) {
2239
2257
  const fields = {
2240
- err: err instanceof Error ? err.message : String(err)
2258
+ err: describeError(err)
2241
2259
  };
2242
2260
  if (err instanceof ApiError) {
2243
2261
  fields.status = err.status;
@@ -2256,6 +2274,7 @@ import { randomUUID as randomUUID4 } from "crypto";
2256
2274
 
2257
2275
  // src/api.ts
2258
2276
  var RETRY_BACKOFF_MS = [250, 750];
2277
+ var TURN_CONTEXT_BACKOFF_MS = [250, 750, 2e3, 5e3];
2259
2278
  var ACTIVE_RUN_OUTBOX_SEQ = 0;
2260
2279
  var LEASE_CHECK_TIMEOUT_MS = 1e4;
2261
2280
  function activeRunOutboxKey(conversationId, agentId) {
@@ -2306,14 +2325,26 @@ var CabaneApi = class {
2306
2325
  return parsed;
2307
2326
  }
2308
2327
  async request(method, path, body, opts = {}) {
2309
- const { signal, retry = false } = opts;
2310
- const maxAttempts = retry ? RETRY_BACKOFF_MS.length + 1 : 1;
2328
+ const { signal, retry = method === "GET", backoffMs = RETRY_BACKOFF_MS } = opts;
2329
+ const maxAttempts = retry ? backoffMs.length + 1 : 1;
2311
2330
  for (let attempt = 1; ; attempt++) {
2312
2331
  try {
2313
2332
  return await this.attempt(method, path, body, signal);
2314
2333
  } catch (err) {
2315
2334
  if (attempt >= maxAttempts || signal?.aborted || !isRetryable(err)) throw err;
2316
- await sleep(RETRY_BACKOFF_MS[attempt - 1], signal);
2335
+ const delayMs = backoffMs[attempt - 1];
2336
+ this.opts.log?.info(
2337
+ {
2338
+ method,
2339
+ path: pathForLog(path),
2340
+ attempt,
2341
+ maxAttempts,
2342
+ delayMs,
2343
+ err: describeError(err)
2344
+ },
2345
+ "companion: API call failed transiently; retrying"
2346
+ );
2347
+ await sleep(delayMs, signal);
2317
2348
  if (signal?.aborted) throw err;
2318
2349
  }
2319
2350
  }
@@ -2451,9 +2482,16 @@ var CabaneApi = class {
2451
2482
  // CT1543: `episode` is the dispatch event's own, echoed back so the server can
2452
2483
  // tell this frame from a later wake on the same message. Omitted when the
2453
2484
  // event didn't carry one, which is the only thing an older event can mean.
2454
- getTurnContext(conversationId, messageId2, turnId, resumed, fresh, episode) {
2485
+ // CT1657: `signal` is the turn's own abort. It matters here more than on any
2486
+ // other read, because this call now waits up to ~8s across five attempts: a
2487
+ // Stop landing in that window used to leave the whole schedule running, and
2488
+ // the person watched a turn they had already stopped keep trying.
2489
+ getTurnContext(conversationId, messageId2, turnId, resumed, fresh, episode, signal) {
2455
2490
  const q = `conversationId=${encodeURIComponent(conversationId)}&messageId=${encodeURIComponent(messageId2)}` + (turnId ? `&turnId=${encodeURIComponent(turnId)}` : "") + (resumed ? "&resumed=1" : "") + (fresh ? "&fresh=1" : "") + (episode !== void 0 ? `&episode=${encodeURIComponent(String(episode))}` : "");
2456
- return this.request("GET", `/api/agent/turn-context?${q}`);
2491
+ return this.request("GET", `/api/agent/turn-context?${q}`, void 0, {
2492
+ backoffMs: this.opts.turnContextBackoffMs ?? TURN_CONTEXT_BACKOFF_MS,
2493
+ ...signal ? { signal } : {}
2494
+ });
2457
2495
  }
2458
2496
  // CT1380: the local half of a resumed turn's seq floor (see
2459
2497
  // `Outbox.maxSeqForTurn`). 0 when no outbox is configured — a one-shot CLI or
@@ -2538,6 +2576,12 @@ var CabaneApi = class {
2538
2576
  `/api/workspaces/${workspaceId}/conversations/${conversationId}/participants/agents/${agentId}/turn-lease?${q}`,
2539
2577
  void 0,
2540
2578
  {
2579
+ // CT1657: the one GET that opts OUT of the default retry. The reasoning
2580
+ // is right above — a failed probe has no answer worth replaying and the
2581
+ // next tick asks again in seconds, so retrying inside this one would
2582
+ // stretch a tick over its successor's slot, which is what the deadline
2583
+ // exists to prevent.
2584
+ retry: false,
2541
2585
  signal: AbortSignal.timeout(this.opts.leaseCheckTimeoutMs ?? LEASE_CHECK_TIMEOUT_MS)
2542
2586
  }
2543
2587
  );
@@ -2729,6 +2773,10 @@ function isRetryable(err) {
2729
2773
  function isAbortError(err) {
2730
2774
  return err instanceof Error && err.name === "AbortError";
2731
2775
  }
2776
+ function pathForLog(path) {
2777
+ const q = path.indexOf("?");
2778
+ return q === -1 ? path : path.slice(0, q);
2779
+ }
2732
2780
  function sleep(ms, signal) {
2733
2781
  return new Promise((resolve2) => {
2734
2782
  if (signal?.aborted) return resolve2();
@@ -8992,6 +9040,7 @@ var TurnExecution = class {
8992
9040
  // identity-checked at the supervisor — then clear the active-run flag);
8993
9041
  // everything past the lease settles in `execute()`'s one `finally`.
8994
9042
  async run() {
9043
+ this.supervisor.registerAbort(this.turnId, this.abortController);
8995
9044
  try {
8996
9045
  await this.fetchContext();
8997
9046
  this.gateTrigger();
@@ -9013,6 +9062,8 @@ var TurnExecution = class {
9013
9062
  this.supervisor.releaseAbort(this.turnId, this.abortController);
9014
9063
  const msg = err instanceof Error ? err.message : String(err);
9015
9064
  await this.concludeAdmittedRun("setup_failed", `setup_failed: ${msg}`);
9065
+ } else {
9066
+ this.supervisor.releaseAbort(this.turnId, this.abortController);
9016
9067
  }
9017
9068
  throw err;
9018
9069
  }
@@ -9094,9 +9145,14 @@ var TurnExecution = class {
9094
9145
  turnId,
9095
9146
  this.resumed,
9096
9147
  void 0,
9097
- payload.episode
9148
+ payload.episode,
9149
+ this.abortController.signal
9098
9150
  );
9099
9151
  } catch (err) {
9152
+ if (this.abortController.signal.aborted) {
9153
+ turnLog.debug("dispatcher: stopped while loading the turn context");
9154
+ throw this.concluded("cancelled");
9155
+ }
9100
9156
  const status = err instanceof ApiError ? err.status : 0;
9101
9157
  if (status === 404) {
9102
9158
  turnLog.debug(
@@ -9111,9 +9167,8 @@ var TurnExecution = class {
9111
9167
  );
9112
9168
  throw this.concluded("turn_context_not_admitted");
9113
9169
  }
9114
- const reason = err instanceof Error ? err.message : String(err);
9115
- turnLog.debug({ err: reason }, "dispatcher: failed to fetch turn context");
9116
- const fetchReason = `fetch_failed: ${reason}`;
9170
+ turnLog.warn(apiErrorLogFields(err), "dispatcher: failed to fetch turn context");
9171
+ const fetchReason = `fetch_failed: ${describeError(err)}`;
9117
9172
  throw this.concluded(fetchReason, fetchReason);
9118
9173
  }
9119
9174
  this.turnContext = turnContext;
@@ -9476,7 +9531,12 @@ var TurnExecution = class {
9476
9531
  payload.messageId,
9477
9532
  turnId,
9478
9533
  false,
9479
- true
9534
+ true,
9535
+ void 0,
9536
+ // CT1657: the turn's abort. This recompose happens mid-run with the
9537
+ // adapter waiting on it, so a Stop here must end the wait rather than
9538
+ // hold the turn open for the rest of the retry schedule.
9539
+ this.abortController.signal
9480
9540
  );
9481
9541
  turnLog.debug(
9482
9542
  { reason },
@@ -9488,6 +9548,10 @@ var TurnExecution = class {
9488
9548
  content: context.content
9489
9549
  };
9490
9550
  } catch (err) {
9551
+ if (this.abortController.signal.aborted) {
9552
+ turnLog.debug({ reason }, "dispatcher: stopped while recomposing the turn context");
9553
+ return null;
9554
+ }
9491
9555
  turnLog.error(
9492
9556
  { reason, ...apiErrorLogFields(err) },
9493
9557
  "Couldn't load the full conversation; continuing with the recent messages."
@@ -10013,6 +10077,41 @@ async function enumerateOpencodeModels(serverUrl, fetchImpl = fetch) {
10013
10077
  }
10014
10078
  }
10015
10079
 
10080
+ // src/dispatch-claims.ts
10081
+ var DispatchClaims = class {
10082
+ inFlight = /* @__PURE__ */ new Map();
10083
+ releases = /* @__PURE__ */ new Map();
10084
+ // Claim a dispatch id for this process. Synchronous by design: two copies can
10085
+ // arrive in the same tick, and a claim taken after an `await` would let both
10086
+ // through.
10087
+ claim(dispatchId) {
10088
+ const running = this.inFlight.get(dispatchId);
10089
+ if (running) return { first: false, done: running };
10090
+ let release;
10091
+ this.inFlight.set(
10092
+ dispatchId,
10093
+ new Promise((resolve2) => {
10094
+ release = resolve2;
10095
+ })
10096
+ );
10097
+ this.releases.set(dispatchId, release);
10098
+ return { first: true };
10099
+ }
10100
+ // Hand the claim back once the run has finished — whatever its outcome.
10101
+ // Releasing an id that isn't claimed is a no-op.
10102
+ release(dispatchId) {
10103
+ const resolve2 = this.releases.get(dispatchId);
10104
+ if (!resolve2) return;
10105
+ this.releases.delete(dispatchId);
10106
+ this.inFlight.delete(dispatchId);
10107
+ resolve2();
10108
+ }
10109
+ // The live set's size — the concurrent-turn count.
10110
+ get size() {
10111
+ return this.inFlight.size;
10112
+ }
10113
+ };
10114
+
10016
10115
  // src/outbox.ts
10017
10116
  import {
10018
10117
  existsSync as existsSync12,
@@ -10359,6 +10458,13 @@ var DRAIN_BASE_MS = 1e3;
10359
10458
  var DRAIN_MAX_MS = 3e4;
10360
10459
  var DRAIN_IDLE_MS = 15e3;
10361
10460
  var DEFAULT_UNRUNNABLE_RETRY_DELAYS_MS = [0, 1e3, 3e3];
10461
+ function dispatchPayload(wire) {
10462
+ if (wire.type !== "device:dispatch_requested") return null;
10463
+ return {
10464
+ type: "device:dispatch_requested",
10465
+ ...wire.payload
10466
+ };
10467
+ }
10362
10468
  var CompanionSupervisor = class {
10363
10469
  workspaces = /* @__PURE__ */ new Map();
10364
10470
  config;
@@ -10417,6 +10523,9 @@ var CompanionSupervisor = class {
10417
10523
  // turn's classified outcome here (per runtime); the heartbeat echoes it so the
10418
10524
  // server's per-connector quota state survives a restart/race.
10419
10525
  connectorHealth = new ConnectorHealthStore();
10526
+ // ACC10: which dispatches this process is running, so the copy of a frame that
10527
+ // arrives on the other stream doesn't run the turn a second time.
10528
+ claims = new DispatchClaims();
10420
10529
  constructor(opts) {
10421
10530
  this.config = opts.config;
10422
10531
  this.log = opts.log;
@@ -10460,7 +10569,9 @@ var CompanionSupervisor = class {
10460
10569
  if (ev.event === "assignments_changed") {
10461
10570
  if (this.refreshing && this.inFlightRefresh) await this.inFlightRefresh;
10462
10571
  await this.refreshAssignments();
10572
+ return;
10463
10573
  }
10574
+ if (ev.event === "message") await this.handleDeviceMessage(ev);
10464
10575
  },
10465
10576
  onAuthFailure: () => {
10466
10577
  this.log.error(
@@ -10971,17 +11082,75 @@ var CompanionSupervisor = class {
10971
11082
  async handleWorkspaceMessage(wr, ev) {
10972
11083
  if (ev.event !== "message") return;
10973
11084
  this.hub.recordEvent(wr.workspaceId);
10974
- let wire;
11085
+ const wire = this.parseFrame(ev);
11086
+ if (!wire) return;
11087
+ if (ev.id) wr.cursor.observe(ev.id);
11088
+ await this.deliverFrame(wr, ev.id, wire);
11089
+ }
11090
+ // ACC10: the device stream's frames — dispatch and cancel, addressed to this
11091
+ // device and authenticated as it. Same frames and the same routing as above;
11092
+ // what differs is that this stream keeps no cursor (it replays what is still
11093
+ // offered on every connect instead of resuming from a `Last-Event-ID`), so
11094
+ // nothing here observes or settles one.
11095
+ async handleDeviceMessage(ev) {
11096
+ const wire = this.parseFrame(ev);
11097
+ if (!wire) return;
11098
+ let wr = this.workspaces.get(wire.workspaceId);
11099
+ if (!wr) {
11100
+ if (this.refreshing && this.inFlightRefresh) await this.inFlightRefresh;
11101
+ await this.refreshAssignments();
11102
+ wr = this.workspaces.get(wire.workspaceId);
11103
+ if (!wr) {
11104
+ const payload = dispatchPayload(wire);
11105
+ if (payload && payload.deviceId === this.deviceId) {
11106
+ await this.reportUnrunnableDispatch(wire.workspaceId, payload);
11107
+ } else {
11108
+ this.log.debug(
11109
+ { workspaceId: wire.workspaceId, type: wire.type },
11110
+ "companion: device frame for a workspace this device does not run; ignoring"
11111
+ );
11112
+ }
11113
+ return;
11114
+ }
11115
+ }
11116
+ this.hub.recordEvent(wr.workspaceId);
11117
+ await this.deliverFrame(wr, "", wire);
11118
+ }
11119
+ parseFrame(ev) {
10975
11120
  try {
10976
- wire = JSON.parse(ev.data);
11121
+ return JSON.parse(ev.data);
10977
11122
  } catch (err) {
10978
11123
  this.log.debug(
10979
11124
  { err: err instanceof Error ? err.message : String(err) },
10980
11125
  "malformed SSE payload"
10981
11126
  );
11127
+ return null;
11128
+ }
11129
+ }
11130
+ // ACC10: the dedupe gate, and the only place that exists because there are two
11131
+ // streams. Both carry the same frame with the same dispatch id; the first copy
11132
+ // to arrive runs the turn, and the second waits for it and then settles its own
11133
+ // stream's cursor. Settling a duplicate earlier than that would advance the
11134
+ // low-water mark past an in-flight turn, which is exactly the CT282 bug.
11135
+ //
11136
+ // A cancel needs no gate: `dispatcher.cancel` on an already-aborted run is a
11137
+ // no-op that reports `false`, so a second copy is inert.
11138
+ async deliverFrame(wr, cursorId, wire) {
11139
+ const claim = wire.type === "device:dispatch_requested" && wire.id ? this.claims.claim(wire.id) : null;
11140
+ if (claim && !claim.first) {
11141
+ await claim.done;
11142
+ if (cursorId) wr.cursor.settle(cursorId);
10982
11143
  return;
10983
11144
  }
10984
- if (ev.id) wr.cursor.observe(ev.id);
11145
+ try {
11146
+ await this.routeFrame(wr, cursorId, wire);
11147
+ } finally {
11148
+ if (claim) this.claims.release(wire.id);
11149
+ }
11150
+ }
11151
+ // Everything below here is the routing as it was: it takes a frame and knows
11152
+ // nothing about which stream carried it.
11153
+ async routeFrame(wr, cursorId, wire) {
10985
11154
  if (wire.type === "device:cancel_requested") {
10986
11155
  const payload2 = {
10987
11156
  type: "device:cancel_requested",
@@ -11001,17 +11170,14 @@ var CompanionSupervisor = class {
11001
11170
  );
11002
11171
  }
11003
11172
  }
11004
- if (ev.id) wr.cursor.settle(ev.id);
11173
+ if (cursorId) wr.cursor.settle(cursorId);
11005
11174
  return;
11006
11175
  }
11007
- if (wire.type !== "device:dispatch_requested") {
11008
- if (ev.id) wr.cursor.settle(ev.id);
11176
+ const payload = dispatchPayload(wire);
11177
+ if (!payload) {
11178
+ if (cursorId) wr.cursor.settle(cursorId);
11009
11179
  return;
11010
11180
  }
11011
- const payload = {
11012
- type: "device:dispatch_requested",
11013
- ...wire.payload
11014
- };
11015
11181
  if (this.deviceId && payload.deviceId && payload.deviceId !== this.deviceId) {
11016
11182
  this.log.debug(
11017
11183
  {
@@ -11022,7 +11188,7 @@ var CompanionSupervisor = class {
11022
11188
  },
11023
11189
  "companion: dispatch addressed to another device; ignoring"
11024
11190
  );
11025
- if (ev.id) wr.cursor.settle(ev.id);
11191
+ if (cursorId) wr.cursor.settle(cursorId);
11026
11192
  return;
11027
11193
  }
11028
11194
  let agent = wr.agents.get(payload.agentId);
@@ -11030,16 +11196,16 @@ var CompanionSupervisor = class {
11030
11196
  agent = await this.recoverRacedAgent(wr, payload);
11031
11197
  if (!agent) {
11032
11198
  if (this.deviceId && payload.deviceId === this.deviceId) {
11033
- const reported = await this.reportUnrunnableDispatch(wr, payload);
11199
+ const reported = await this.reportUnrunnableDispatch(wr.workspaceId, payload);
11034
11200
  if (!reported) return;
11035
11201
  }
11036
- if (ev.id) wr.cursor.settle(ev.id);
11202
+ if (cursorId) wr.cursor.settle(cursorId);
11037
11203
  return;
11038
11204
  }
11039
11205
  }
11040
11206
  const chainKey = `${payload.conversationId}|${payload.agentId}`;
11041
11207
  const prev = wr.chains.get(chainKey) ?? Promise.resolve();
11042
- const tail = prev.then(() => this.runDispatch(wr, ev, payload, agent)).catch((err) => {
11208
+ const tail = prev.then(() => this.runDispatch(wr, { dispatchId: wire.id, cursorId }, payload, agent)).catch((err) => {
11043
11209
  this.log.warn(
11044
11210
  {
11045
11211
  workspaceId: wr.workspaceId,
@@ -11066,7 +11232,7 @@ var CompanionSupervisor = class {
11066
11232
  // dropped socket) and one more attempt a second later usually lands. Bounded
11067
11233
  // hard — this runs inline on the event loop, and an unbounded wait here would
11068
11234
  // wedge the workspace's whole stream.
11069
- async reportUnrunnableDispatch(wr, payload) {
11235
+ async reportUnrunnableDispatch(workspaceId, payload) {
11070
11236
  if (!this.deviceApi) return false;
11071
11237
  let lastErr;
11072
11238
  for (const wait of this.unrunnableRetryDelaysMs) {
@@ -11074,7 +11240,7 @@ var CompanionSupervisor = class {
11074
11240
  if (wait > 0) await new Promise((r) => setTimeout(r, wait));
11075
11241
  try {
11076
11242
  const { cleared } = await this.deviceApi.reportDispatchDrop({
11077
- workspaceId: wr.workspaceId,
11243
+ workspaceId,
11078
11244
  conversationId: payload.conversationId,
11079
11245
  agentId: payload.agentId,
11080
11246
  messageId: payload.messageId,
@@ -11082,7 +11248,7 @@ var CompanionSupervisor = class {
11082
11248
  });
11083
11249
  this.log.debug(
11084
11250
  {
11085
- workspaceId: wr.workspaceId,
11251
+ workspaceId,
11086
11252
  conversationId: payload.conversationId,
11087
11253
  agentId: payload.agentId,
11088
11254
  cleared
@@ -11096,7 +11262,7 @@ var CompanionSupervisor = class {
11096
11262
  }
11097
11263
  this.log.error(
11098
11264
  {
11099
- workspaceId: wr.workspaceId,
11265
+ workspaceId,
11100
11266
  conversationId: payload.conversationId,
11101
11267
  agentId: payload.agentId,
11102
11268
  err: lastErr instanceof Error ? lastErr.message : String(lastErr)
@@ -11151,7 +11317,10 @@ var CompanionSupervisor = class {
11151
11317
  };
11152
11318
  const tail = this.runDispatch(
11153
11319
  wr,
11154
- { id: "", event: "recovery", data: "" },
11320
+ // No frame delivered this one — it came off `/runs` — so there is
11321
+ // neither a dispatch id to dedupe on nor a cursor to settle. The
11322
+ // `recoveredTurns` set above is this path's own guard.
11323
+ { dispatchId: "", cursorId: "" },
11155
11324
  payload,
11156
11325
  agent,
11157
11326
  run.turnId
@@ -11175,56 +11344,59 @@ var CompanionSupervisor = class {
11175
11344
  // One turn: the SJ516 F7 / CT26 skip-vs-resume dedupe, the dispatch, then
11176
11345
  // cursor + console summary. Mirrors the old per-workspace `handleEvent`, now
11177
11346
  // scoped to one agent.
11178
- async runDispatch(wr, ev, payload, agent, recoveredTurnId) {
11347
+ async runDispatch(wr, ids, payload, agent, recoveredTurnId) {
11179
11348
  const workspaceId = wr.workspaceId;
11180
- if (ev.id && hasDispatched(workspaceId, ev.id)) {
11181
- if (hasCompleted(workspaceId, ev.id)) {
11349
+ const settleCursor = () => {
11350
+ if (ids.cursorId) wr.cursor.settle(ids.cursorId);
11351
+ };
11352
+ if (ids.dispatchId && hasDispatched(workspaceId, ids.dispatchId)) {
11353
+ if (hasCompleted(workspaceId, ids.dispatchId)) {
11182
11354
  this.log.debug(
11183
- { workspaceId, eventId: ev.id },
11355
+ { workspaceId, eventId: ids.dispatchId },
11184
11356
  "companion: skipping already-completed event (resume after restart)"
11185
11357
  );
11186
- wr.cursor.settle(ev.id);
11358
+ settleCursor();
11187
11359
  return;
11188
11360
  }
11189
11361
  if (noResume()) {
11190
11362
  this.log.debug(
11191
- { workspaceId, eventId: ev.id },
11363
+ { workspaceId, eventId: ids.dispatchId },
11192
11364
  "companion: skipping interrupted turn (CABANE_COMPANION_NO_RESUME=1) \u2014 resume disabled for this boot"
11193
11365
  );
11194
- markCompleted(workspaceId, ev.id);
11195
- wr.cursor.settle(ev.id);
11366
+ markCompleted(workspaceId, ids.dispatchId);
11367
+ settleCursor();
11196
11368
  return;
11197
11369
  }
11198
- const attempt = bumpResumeAttempt(workspaceId, ev.id);
11370
+ const attempt = bumpResumeAttempt(workspaceId, ids.dispatchId);
11199
11371
  if (attempt > MAX_RESUME_ATTEMPTS) {
11200
11372
  this.log.error(
11201
- { workspaceId, eventId: ev.id, attempt, cap: MAX_RESUME_ATTEMPTS },
11373
+ { workspaceId, eventId: ids.dispatchId, attempt, cap: MAX_RESUME_ATTEMPTS },
11202
11374
  "An interrupted turn couldn't finish after several attempts. Reply in Cabane to try again."
11203
11375
  );
11204
- markCompleted(workspaceId, ev.id);
11205
- wr.cursor.settle(ev.id);
11376
+ markCompleted(workspaceId, ids.dispatchId);
11377
+ settleCursor();
11206
11378
  return;
11207
11379
  }
11208
11380
  this.log.debug(
11209
- { workspaceId, eventId: ev.id, attempt, cap: MAX_RESUME_ATTEMPTS },
11381
+ { workspaceId, eventId: ids.dispatchId, attempt, cap: MAX_RESUME_ATTEMPTS },
11210
11382
  "companion: re-dispatching interrupted turn (resume after restart)"
11211
11383
  );
11212
11384
  }
11213
- const resumedTurnId = recoveredTurnId ?? (ev.id ? turnIdForEvent(workspaceId, ev.id) : null);
11385
+ const resumedTurnId = recoveredTurnId ?? (ids.dispatchId ? turnIdForEvent(workspaceId, ids.dispatchId) : null);
11214
11386
  const turnId = resumedTurnId ?? randomUUID4();
11215
- if (ev.id) {
11216
- const liveTurnIds = rememberTurnId(workspaceId, ev.id, turnId);
11387
+ if (ids.dispatchId) {
11388
+ const liveTurnIds = rememberTurnId(workspaceId, ids.dispatchId, turnId);
11217
11389
  if (liveTurnIds > TURN_ID_OVERFLOW_WARN) {
11218
11390
  this.log.debug(
11219
11391
  { workspaceId, liveTurnIds, threshold: TURN_ID_OVERFLOW_WARN },
11220
11392
  "companion: in-flight turn-id map is implausibly large \u2014 completion pruning is likely broken. Keeping every mapping; dropping one would silently lose a resumable turn."
11221
11393
  );
11222
11394
  }
11223
- markDispatched(workspaceId, ev.id);
11395
+ markDispatched(workspaceId, ids.dispatchId);
11224
11396
  }
11225
11397
  if (resumedTurnId) {
11226
11398
  this.log.debug(
11227
- { workspaceId, eventId: ev.id, turnId },
11399
+ { workspaceId, eventId: ids.dispatchId, turnId },
11228
11400
  "companion: resuming an interrupted turn under its original id"
11229
11401
  );
11230
11402
  }
@@ -11239,8 +11411,8 @@ var CompanionSupervisor = class {
11239
11411
  turnId,
11240
11412
  resumed: resumedTurnId !== null
11241
11413
  });
11242
- if (ev.id) markCompleted(workspaceId, ev.id);
11243
- if (ev.id) wr.cursor.settle(ev.id);
11414
+ if (ids.dispatchId) markCompleted(workspaceId, ids.dispatchId);
11415
+ settleCursor();
11244
11416
  const durationS = (result.durationMs / 1e3).toFixed(1);
11245
11417
  const bindings = {
11246
11418
  ...turnBindings,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cabane/companion",
3
- "version": "0.6.111",
3
+ "version": "0.6.113",
4
4
  "type": "module",
5
5
  "description": "The Cabane Companion (headless): connect a coding agent on your machine to your Cabane workspace as a responder — drive work against your own codebase, files, and MCP servers without putting any of it in Cabane.",
6
6
  "license": "UNLICENSED",