@cabane/companion 0.6.112 → 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 +147 -39
  2. package/dist/runtime.js +147 -39
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -10654,6 +10654,41 @@ async function enumerateOpencodeModels(serverUrl, fetchImpl = fetch) {
10654
10654
  }
10655
10655
  }
10656
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
+
10657
10692
  // src/outbox.ts
10658
10693
  import {
10659
10694
  existsSync as existsSync12,
@@ -11000,6 +11035,13 @@ var DRAIN_BASE_MS = 1e3;
11000
11035
  var DRAIN_MAX_MS = 3e4;
11001
11036
  var DRAIN_IDLE_MS = 15e3;
11002
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
+ }
11003
11045
  var CompanionSupervisor = class {
11004
11046
  workspaces = /* @__PURE__ */ new Map();
11005
11047
  config;
@@ -11058,6 +11100,9 @@ var CompanionSupervisor = class {
11058
11100
  // turn's classified outcome here (per runtime); the heartbeat echoes it so the
11059
11101
  // server's per-connector quota state survives a restart/race.
11060
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();
11061
11106
  constructor(opts) {
11062
11107
  this.config = opts.config;
11063
11108
  this.log = opts.log;
@@ -11101,7 +11146,9 @@ var CompanionSupervisor = class {
11101
11146
  if (ev.event === "assignments_changed") {
11102
11147
  if (this.refreshing && this.inFlightRefresh) await this.inFlightRefresh;
11103
11148
  await this.refreshAssignments();
11149
+ return;
11104
11150
  }
11151
+ if (ev.event === "message") await this.handleDeviceMessage(ev);
11105
11152
  },
11106
11153
  onAuthFailure: () => {
11107
11154
  this.log.error(
@@ -11612,17 +11659,75 @@ var CompanionSupervisor = class {
11612
11659
  async handleWorkspaceMessage(wr, ev) {
11613
11660
  if (ev.event !== "message") return;
11614
11661
  this.hub.recordEvent(wr.workspaceId);
11615
- 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) {
11616
11697
  try {
11617
- wire = JSON.parse(ev.data);
11698
+ return JSON.parse(ev.data);
11618
11699
  } catch (err) {
11619
11700
  this.log.debug(
11620
11701
  { err: err instanceof Error ? err.message : String(err) },
11621
11702
  "malformed SSE payload"
11622
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);
11623
11720
  return;
11624
11721
  }
11625
- 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) {
11626
11731
  if (wire.type === "device:cancel_requested") {
11627
11732
  const payload2 = {
11628
11733
  type: "device:cancel_requested",
@@ -11642,17 +11747,14 @@ var CompanionSupervisor = class {
11642
11747
  );
11643
11748
  }
11644
11749
  }
11645
- if (ev.id) wr.cursor.settle(ev.id);
11750
+ if (cursorId) wr.cursor.settle(cursorId);
11646
11751
  return;
11647
11752
  }
11648
- if (wire.type !== "device:dispatch_requested") {
11649
- if (ev.id) wr.cursor.settle(ev.id);
11753
+ const payload = dispatchPayload(wire);
11754
+ if (!payload) {
11755
+ if (cursorId) wr.cursor.settle(cursorId);
11650
11756
  return;
11651
11757
  }
11652
- const payload = {
11653
- type: "device:dispatch_requested",
11654
- ...wire.payload
11655
- };
11656
11758
  if (this.deviceId && payload.deviceId && payload.deviceId !== this.deviceId) {
11657
11759
  this.log.debug(
11658
11760
  {
@@ -11663,7 +11765,7 @@ var CompanionSupervisor = class {
11663
11765
  },
11664
11766
  "companion: dispatch addressed to another device; ignoring"
11665
11767
  );
11666
- if (ev.id) wr.cursor.settle(ev.id);
11768
+ if (cursorId) wr.cursor.settle(cursorId);
11667
11769
  return;
11668
11770
  }
11669
11771
  let agent = wr.agents.get(payload.agentId);
@@ -11671,16 +11773,16 @@ var CompanionSupervisor = class {
11671
11773
  agent = await this.recoverRacedAgent(wr, payload);
11672
11774
  if (!agent) {
11673
11775
  if (this.deviceId && payload.deviceId === this.deviceId) {
11674
- const reported = await this.reportUnrunnableDispatch(wr, payload);
11776
+ const reported = await this.reportUnrunnableDispatch(wr.workspaceId, payload);
11675
11777
  if (!reported) return;
11676
11778
  }
11677
- if (ev.id) wr.cursor.settle(ev.id);
11779
+ if (cursorId) wr.cursor.settle(cursorId);
11678
11780
  return;
11679
11781
  }
11680
11782
  }
11681
11783
  const chainKey = `${payload.conversationId}|${payload.agentId}`;
11682
11784
  const prev = wr.chains.get(chainKey) ?? Promise.resolve();
11683
- 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) => {
11684
11786
  this.log.warn(
11685
11787
  {
11686
11788
  workspaceId: wr.workspaceId,
@@ -11707,7 +11809,7 @@ var CompanionSupervisor = class {
11707
11809
  // dropped socket) and one more attempt a second later usually lands. Bounded
11708
11810
  // hard — this runs inline on the event loop, and an unbounded wait here would
11709
11811
  // wedge the workspace's whole stream.
11710
- async reportUnrunnableDispatch(wr, payload) {
11812
+ async reportUnrunnableDispatch(workspaceId, payload) {
11711
11813
  if (!this.deviceApi) return false;
11712
11814
  let lastErr;
11713
11815
  for (const wait of this.unrunnableRetryDelaysMs) {
@@ -11715,7 +11817,7 @@ var CompanionSupervisor = class {
11715
11817
  if (wait > 0) await new Promise((r) => setTimeout(r, wait));
11716
11818
  try {
11717
11819
  const { cleared } = await this.deviceApi.reportDispatchDrop({
11718
- workspaceId: wr.workspaceId,
11820
+ workspaceId,
11719
11821
  conversationId: payload.conversationId,
11720
11822
  agentId: payload.agentId,
11721
11823
  messageId: payload.messageId,
@@ -11723,7 +11825,7 @@ var CompanionSupervisor = class {
11723
11825
  });
11724
11826
  this.log.debug(
11725
11827
  {
11726
- workspaceId: wr.workspaceId,
11828
+ workspaceId,
11727
11829
  conversationId: payload.conversationId,
11728
11830
  agentId: payload.agentId,
11729
11831
  cleared
@@ -11737,7 +11839,7 @@ var CompanionSupervisor = class {
11737
11839
  }
11738
11840
  this.log.error(
11739
11841
  {
11740
- workspaceId: wr.workspaceId,
11842
+ workspaceId,
11741
11843
  conversationId: payload.conversationId,
11742
11844
  agentId: payload.agentId,
11743
11845
  err: lastErr instanceof Error ? lastErr.message : String(lastErr)
@@ -11792,7 +11894,10 @@ var CompanionSupervisor = class {
11792
11894
  };
11793
11895
  const tail = this.runDispatch(
11794
11896
  wr,
11795
- { 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: "" },
11796
11901
  payload,
11797
11902
  agent,
11798
11903
  run.turnId
@@ -11816,56 +11921,59 @@ var CompanionSupervisor = class {
11816
11921
  // One turn: the SJ516 F7 / CT26 skip-vs-resume dedupe, the dispatch, then
11817
11922
  // cursor + console summary. Mirrors the old per-workspace `handleEvent`, now
11818
11923
  // scoped to one agent.
11819
- async runDispatch(wr, ev, payload, agent, recoveredTurnId) {
11924
+ async runDispatch(wr, ids, payload, agent, recoveredTurnId) {
11820
11925
  const workspaceId = wr.workspaceId;
11821
- if (ev.id && hasDispatched(workspaceId, ev.id)) {
11822
- 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)) {
11823
11931
  this.log.debug(
11824
- { workspaceId, eventId: ev.id },
11932
+ { workspaceId, eventId: ids.dispatchId },
11825
11933
  "companion: skipping already-completed event (resume after restart)"
11826
11934
  );
11827
- wr.cursor.settle(ev.id);
11935
+ settleCursor();
11828
11936
  return;
11829
11937
  }
11830
11938
  if (noResume()) {
11831
11939
  this.log.debug(
11832
- { workspaceId, eventId: ev.id },
11940
+ { workspaceId, eventId: ids.dispatchId },
11833
11941
  "companion: skipping interrupted turn (CABANE_COMPANION_NO_RESUME=1) \u2014 resume disabled for this boot"
11834
11942
  );
11835
- markCompleted(workspaceId, ev.id);
11836
- wr.cursor.settle(ev.id);
11943
+ markCompleted(workspaceId, ids.dispatchId);
11944
+ settleCursor();
11837
11945
  return;
11838
11946
  }
11839
- const attempt = bumpResumeAttempt(workspaceId, ev.id);
11947
+ const attempt = bumpResumeAttempt(workspaceId, ids.dispatchId);
11840
11948
  if (attempt > MAX_RESUME_ATTEMPTS) {
11841
11949
  this.log.error(
11842
- { workspaceId, eventId: ev.id, attempt, cap: MAX_RESUME_ATTEMPTS },
11950
+ { workspaceId, eventId: ids.dispatchId, attempt, cap: MAX_RESUME_ATTEMPTS },
11843
11951
  "An interrupted turn couldn't finish after several attempts. Reply in Cabane to try again."
11844
11952
  );
11845
- markCompleted(workspaceId, ev.id);
11846
- wr.cursor.settle(ev.id);
11953
+ markCompleted(workspaceId, ids.dispatchId);
11954
+ settleCursor();
11847
11955
  return;
11848
11956
  }
11849
11957
  this.log.debug(
11850
- { workspaceId, eventId: ev.id, attempt, cap: MAX_RESUME_ATTEMPTS },
11958
+ { workspaceId, eventId: ids.dispatchId, attempt, cap: MAX_RESUME_ATTEMPTS },
11851
11959
  "companion: re-dispatching interrupted turn (resume after restart)"
11852
11960
  );
11853
11961
  }
11854
- const resumedTurnId = recoveredTurnId ?? (ev.id ? turnIdForEvent(workspaceId, ev.id) : null);
11962
+ const resumedTurnId = recoveredTurnId ?? (ids.dispatchId ? turnIdForEvent(workspaceId, ids.dispatchId) : null);
11855
11963
  const turnId = resumedTurnId ?? randomUUID4();
11856
- if (ev.id) {
11857
- const liveTurnIds = rememberTurnId(workspaceId, ev.id, turnId);
11964
+ if (ids.dispatchId) {
11965
+ const liveTurnIds = rememberTurnId(workspaceId, ids.dispatchId, turnId);
11858
11966
  if (liveTurnIds > TURN_ID_OVERFLOW_WARN) {
11859
11967
  this.log.debug(
11860
11968
  { workspaceId, liveTurnIds, threshold: TURN_ID_OVERFLOW_WARN },
11861
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."
11862
11970
  );
11863
11971
  }
11864
- markDispatched(workspaceId, ev.id);
11972
+ markDispatched(workspaceId, ids.dispatchId);
11865
11973
  }
11866
11974
  if (resumedTurnId) {
11867
11975
  this.log.debug(
11868
- { workspaceId, eventId: ev.id, turnId },
11976
+ { workspaceId, eventId: ids.dispatchId, turnId },
11869
11977
  "companion: resuming an interrupted turn under its original id"
11870
11978
  );
11871
11979
  }
@@ -11880,8 +11988,8 @@ var CompanionSupervisor = class {
11880
11988
  turnId,
11881
11989
  resumed: resumedTurnId !== null
11882
11990
  });
11883
- if (ev.id) markCompleted(workspaceId, ev.id);
11884
- if (ev.id) wr.cursor.settle(ev.id);
11991
+ if (ids.dispatchId) markCompleted(workspaceId, ids.dispatchId);
11992
+ settleCursor();
11885
11993
  const durationS = (result.durationMs / 1e3).toFixed(1);
11886
11994
  const bindings = {
11887
11995
  ...turnBindings,
package/dist/runtime.js CHANGED
@@ -10077,6 +10077,41 @@ async function enumerateOpencodeModels(serverUrl, fetchImpl = fetch) {
10077
10077
  }
10078
10078
  }
10079
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
+
10080
10115
  // src/outbox.ts
10081
10116
  import {
10082
10117
  existsSync as existsSync12,
@@ -10423,6 +10458,13 @@ var DRAIN_BASE_MS = 1e3;
10423
10458
  var DRAIN_MAX_MS = 3e4;
10424
10459
  var DRAIN_IDLE_MS = 15e3;
10425
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
+ }
10426
10468
  var CompanionSupervisor = class {
10427
10469
  workspaces = /* @__PURE__ */ new Map();
10428
10470
  config;
@@ -10481,6 +10523,9 @@ var CompanionSupervisor = class {
10481
10523
  // turn's classified outcome here (per runtime); the heartbeat echoes it so the
10482
10524
  // server's per-connector quota state survives a restart/race.
10483
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();
10484
10529
  constructor(opts) {
10485
10530
  this.config = opts.config;
10486
10531
  this.log = opts.log;
@@ -10524,7 +10569,9 @@ var CompanionSupervisor = class {
10524
10569
  if (ev.event === "assignments_changed") {
10525
10570
  if (this.refreshing && this.inFlightRefresh) await this.inFlightRefresh;
10526
10571
  await this.refreshAssignments();
10572
+ return;
10527
10573
  }
10574
+ if (ev.event === "message") await this.handleDeviceMessage(ev);
10528
10575
  },
10529
10576
  onAuthFailure: () => {
10530
10577
  this.log.error(
@@ -11035,17 +11082,75 @@ var CompanionSupervisor = class {
11035
11082
  async handleWorkspaceMessage(wr, ev) {
11036
11083
  if (ev.event !== "message") return;
11037
11084
  this.hub.recordEvent(wr.workspaceId);
11038
- 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) {
11039
11120
  try {
11040
- wire = JSON.parse(ev.data);
11121
+ return JSON.parse(ev.data);
11041
11122
  } catch (err) {
11042
11123
  this.log.debug(
11043
11124
  { err: err instanceof Error ? err.message : String(err) },
11044
11125
  "malformed SSE payload"
11045
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);
11046
11143
  return;
11047
11144
  }
11048
- 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) {
11049
11154
  if (wire.type === "device:cancel_requested") {
11050
11155
  const payload2 = {
11051
11156
  type: "device:cancel_requested",
@@ -11065,17 +11170,14 @@ var CompanionSupervisor = class {
11065
11170
  );
11066
11171
  }
11067
11172
  }
11068
- if (ev.id) wr.cursor.settle(ev.id);
11173
+ if (cursorId) wr.cursor.settle(cursorId);
11069
11174
  return;
11070
11175
  }
11071
- if (wire.type !== "device:dispatch_requested") {
11072
- if (ev.id) wr.cursor.settle(ev.id);
11176
+ const payload = dispatchPayload(wire);
11177
+ if (!payload) {
11178
+ if (cursorId) wr.cursor.settle(cursorId);
11073
11179
  return;
11074
11180
  }
11075
- const payload = {
11076
- type: "device:dispatch_requested",
11077
- ...wire.payload
11078
- };
11079
11181
  if (this.deviceId && payload.deviceId && payload.deviceId !== this.deviceId) {
11080
11182
  this.log.debug(
11081
11183
  {
@@ -11086,7 +11188,7 @@ var CompanionSupervisor = class {
11086
11188
  },
11087
11189
  "companion: dispatch addressed to another device; ignoring"
11088
11190
  );
11089
- if (ev.id) wr.cursor.settle(ev.id);
11191
+ if (cursorId) wr.cursor.settle(cursorId);
11090
11192
  return;
11091
11193
  }
11092
11194
  let agent = wr.agents.get(payload.agentId);
@@ -11094,16 +11196,16 @@ var CompanionSupervisor = class {
11094
11196
  agent = await this.recoverRacedAgent(wr, payload);
11095
11197
  if (!agent) {
11096
11198
  if (this.deviceId && payload.deviceId === this.deviceId) {
11097
- const reported = await this.reportUnrunnableDispatch(wr, payload);
11199
+ const reported = await this.reportUnrunnableDispatch(wr.workspaceId, payload);
11098
11200
  if (!reported) return;
11099
11201
  }
11100
- if (ev.id) wr.cursor.settle(ev.id);
11202
+ if (cursorId) wr.cursor.settle(cursorId);
11101
11203
  return;
11102
11204
  }
11103
11205
  }
11104
11206
  const chainKey = `${payload.conversationId}|${payload.agentId}`;
11105
11207
  const prev = wr.chains.get(chainKey) ?? Promise.resolve();
11106
- 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) => {
11107
11209
  this.log.warn(
11108
11210
  {
11109
11211
  workspaceId: wr.workspaceId,
@@ -11130,7 +11232,7 @@ var CompanionSupervisor = class {
11130
11232
  // dropped socket) and one more attempt a second later usually lands. Bounded
11131
11233
  // hard — this runs inline on the event loop, and an unbounded wait here would
11132
11234
  // wedge the workspace's whole stream.
11133
- async reportUnrunnableDispatch(wr, payload) {
11235
+ async reportUnrunnableDispatch(workspaceId, payload) {
11134
11236
  if (!this.deviceApi) return false;
11135
11237
  let lastErr;
11136
11238
  for (const wait of this.unrunnableRetryDelaysMs) {
@@ -11138,7 +11240,7 @@ var CompanionSupervisor = class {
11138
11240
  if (wait > 0) await new Promise((r) => setTimeout(r, wait));
11139
11241
  try {
11140
11242
  const { cleared } = await this.deviceApi.reportDispatchDrop({
11141
- workspaceId: wr.workspaceId,
11243
+ workspaceId,
11142
11244
  conversationId: payload.conversationId,
11143
11245
  agentId: payload.agentId,
11144
11246
  messageId: payload.messageId,
@@ -11146,7 +11248,7 @@ var CompanionSupervisor = class {
11146
11248
  });
11147
11249
  this.log.debug(
11148
11250
  {
11149
- workspaceId: wr.workspaceId,
11251
+ workspaceId,
11150
11252
  conversationId: payload.conversationId,
11151
11253
  agentId: payload.agentId,
11152
11254
  cleared
@@ -11160,7 +11262,7 @@ var CompanionSupervisor = class {
11160
11262
  }
11161
11263
  this.log.error(
11162
11264
  {
11163
- workspaceId: wr.workspaceId,
11265
+ workspaceId,
11164
11266
  conversationId: payload.conversationId,
11165
11267
  agentId: payload.agentId,
11166
11268
  err: lastErr instanceof Error ? lastErr.message : String(lastErr)
@@ -11215,7 +11317,10 @@ var CompanionSupervisor = class {
11215
11317
  };
11216
11318
  const tail = this.runDispatch(
11217
11319
  wr,
11218
- { 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: "" },
11219
11324
  payload,
11220
11325
  agent,
11221
11326
  run.turnId
@@ -11239,56 +11344,59 @@ var CompanionSupervisor = class {
11239
11344
  // One turn: the SJ516 F7 / CT26 skip-vs-resume dedupe, the dispatch, then
11240
11345
  // cursor + console summary. Mirrors the old per-workspace `handleEvent`, now
11241
11346
  // scoped to one agent.
11242
- async runDispatch(wr, ev, payload, agent, recoveredTurnId) {
11347
+ async runDispatch(wr, ids, payload, agent, recoveredTurnId) {
11243
11348
  const workspaceId = wr.workspaceId;
11244
- if (ev.id && hasDispatched(workspaceId, ev.id)) {
11245
- 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)) {
11246
11354
  this.log.debug(
11247
- { workspaceId, eventId: ev.id },
11355
+ { workspaceId, eventId: ids.dispatchId },
11248
11356
  "companion: skipping already-completed event (resume after restart)"
11249
11357
  );
11250
- wr.cursor.settle(ev.id);
11358
+ settleCursor();
11251
11359
  return;
11252
11360
  }
11253
11361
  if (noResume()) {
11254
11362
  this.log.debug(
11255
- { workspaceId, eventId: ev.id },
11363
+ { workspaceId, eventId: ids.dispatchId },
11256
11364
  "companion: skipping interrupted turn (CABANE_COMPANION_NO_RESUME=1) \u2014 resume disabled for this boot"
11257
11365
  );
11258
- markCompleted(workspaceId, ev.id);
11259
- wr.cursor.settle(ev.id);
11366
+ markCompleted(workspaceId, ids.dispatchId);
11367
+ settleCursor();
11260
11368
  return;
11261
11369
  }
11262
- const attempt = bumpResumeAttempt(workspaceId, ev.id);
11370
+ const attempt = bumpResumeAttempt(workspaceId, ids.dispatchId);
11263
11371
  if (attempt > MAX_RESUME_ATTEMPTS) {
11264
11372
  this.log.error(
11265
- { workspaceId, eventId: ev.id, attempt, cap: MAX_RESUME_ATTEMPTS },
11373
+ { workspaceId, eventId: ids.dispatchId, attempt, cap: MAX_RESUME_ATTEMPTS },
11266
11374
  "An interrupted turn couldn't finish after several attempts. Reply in Cabane to try again."
11267
11375
  );
11268
- markCompleted(workspaceId, ev.id);
11269
- wr.cursor.settle(ev.id);
11376
+ markCompleted(workspaceId, ids.dispatchId);
11377
+ settleCursor();
11270
11378
  return;
11271
11379
  }
11272
11380
  this.log.debug(
11273
- { workspaceId, eventId: ev.id, attempt, cap: MAX_RESUME_ATTEMPTS },
11381
+ { workspaceId, eventId: ids.dispatchId, attempt, cap: MAX_RESUME_ATTEMPTS },
11274
11382
  "companion: re-dispatching interrupted turn (resume after restart)"
11275
11383
  );
11276
11384
  }
11277
- const resumedTurnId = recoveredTurnId ?? (ev.id ? turnIdForEvent(workspaceId, ev.id) : null);
11385
+ const resumedTurnId = recoveredTurnId ?? (ids.dispatchId ? turnIdForEvent(workspaceId, ids.dispatchId) : null);
11278
11386
  const turnId = resumedTurnId ?? randomUUID4();
11279
- if (ev.id) {
11280
- const liveTurnIds = rememberTurnId(workspaceId, ev.id, turnId);
11387
+ if (ids.dispatchId) {
11388
+ const liveTurnIds = rememberTurnId(workspaceId, ids.dispatchId, turnId);
11281
11389
  if (liveTurnIds > TURN_ID_OVERFLOW_WARN) {
11282
11390
  this.log.debug(
11283
11391
  { workspaceId, liveTurnIds, threshold: TURN_ID_OVERFLOW_WARN },
11284
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."
11285
11393
  );
11286
11394
  }
11287
- markDispatched(workspaceId, ev.id);
11395
+ markDispatched(workspaceId, ids.dispatchId);
11288
11396
  }
11289
11397
  if (resumedTurnId) {
11290
11398
  this.log.debug(
11291
- { workspaceId, eventId: ev.id, turnId },
11399
+ { workspaceId, eventId: ids.dispatchId, turnId },
11292
11400
  "companion: resuming an interrupted turn under its original id"
11293
11401
  );
11294
11402
  }
@@ -11303,8 +11411,8 @@ var CompanionSupervisor = class {
11303
11411
  turnId,
11304
11412
  resumed: resumedTurnId !== null
11305
11413
  });
11306
- if (ev.id) markCompleted(workspaceId, ev.id);
11307
- if (ev.id) wr.cursor.settle(ev.id);
11414
+ if (ids.dispatchId) markCompleted(workspaceId, ids.dispatchId);
11415
+ settleCursor();
11308
11416
  const durationS = (result.durationMs / 1e3).toFixed(1);
11309
11417
  const bindings = {
11310
11418
  ...turnBindings,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cabane/companion",
3
- "version": "0.6.112",
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",