@cabane/companion 0.6.112 → 0.6.114

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 +149 -43
  2. package/dist/runtime.js +149 -43
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -4448,8 +4448,7 @@ var TASK_STAGES = [
4448
4448
  "in_progress",
4449
4449
  "in_review",
4450
4450
  "done",
4451
- "dropped",
4452
- "deferred"
4451
+ "dropped"
4453
4452
  ];
4454
4453
  var MIN_ASK_OPTIONS = 2;
4455
4454
  var MAX_ASK_OPTIONS = 4;
@@ -10654,6 +10653,41 @@ async function enumerateOpencodeModels(serverUrl, fetchImpl = fetch) {
10654
10653
  }
10655
10654
  }
10656
10655
 
10656
+ // src/dispatch-claims.ts
10657
+ var DispatchClaims = class {
10658
+ inFlight = /* @__PURE__ */ new Map();
10659
+ releases = /* @__PURE__ */ new Map();
10660
+ // Claim a dispatch id for this process. Synchronous by design: two copies can
10661
+ // arrive in the same tick, and a claim taken after an `await` would let both
10662
+ // through.
10663
+ claim(dispatchId) {
10664
+ const running = this.inFlight.get(dispatchId);
10665
+ if (running) return { first: false, done: running };
10666
+ let release;
10667
+ this.inFlight.set(
10668
+ dispatchId,
10669
+ new Promise((resolve2) => {
10670
+ release = resolve2;
10671
+ })
10672
+ );
10673
+ this.releases.set(dispatchId, release);
10674
+ return { first: true };
10675
+ }
10676
+ // Hand the claim back once the run has finished — whatever its outcome.
10677
+ // Releasing an id that isn't claimed is a no-op.
10678
+ release(dispatchId) {
10679
+ const resolve2 = this.releases.get(dispatchId);
10680
+ if (!resolve2) return;
10681
+ this.releases.delete(dispatchId);
10682
+ this.inFlight.delete(dispatchId);
10683
+ resolve2();
10684
+ }
10685
+ // The live set's size — the concurrent-turn count.
10686
+ get size() {
10687
+ return this.inFlight.size;
10688
+ }
10689
+ };
10690
+
10657
10691
  // src/outbox.ts
10658
10692
  import {
10659
10693
  existsSync as existsSync12,
@@ -10932,8 +10966,7 @@ var SseSubscriber = class {
10932
10966
  }
10933
10967
  async connect() {
10934
10968
  const base = this.opts.baseUrl.endsWith("/") ? this.opts.baseUrl.slice(0, -1) : this.opts.baseUrl;
10935
- const query = this.opts.path ? "" : this.lastEventId ? "" : "?replayPending=1";
10936
- const url = this.opts.path ? `${base}${this.opts.path}` : `${base}/api/workspaces/${this.opts.workspaceId}/events${query}`;
10969
+ const url = this.opts.path ? `${base}${this.opts.path}` : `${base}/api/workspaces/${this.opts.workspaceId}/events`;
10937
10970
  this.controller = new AbortController();
10938
10971
  const headers = {
10939
10972
  Authorization: `Bearer ${this.opts.token}`,
@@ -11000,6 +11033,13 @@ var DRAIN_BASE_MS = 1e3;
11000
11033
  var DRAIN_MAX_MS = 3e4;
11001
11034
  var DRAIN_IDLE_MS = 15e3;
11002
11035
  var DEFAULT_UNRUNNABLE_RETRY_DELAYS_MS = [0, 1e3, 3e3];
11036
+ function dispatchPayload(wire) {
11037
+ if (wire.type !== "device:dispatch_requested") return null;
11038
+ return {
11039
+ type: "device:dispatch_requested",
11040
+ ...wire.payload
11041
+ };
11042
+ }
11003
11043
  var CompanionSupervisor = class {
11004
11044
  workspaces = /* @__PURE__ */ new Map();
11005
11045
  config;
@@ -11058,6 +11098,9 @@ var CompanionSupervisor = class {
11058
11098
  // turn's classified outcome here (per runtime); the heartbeat echoes it so the
11059
11099
  // server's per-connector quota state survives a restart/race.
11060
11100
  connectorHealth = new ConnectorHealthStore();
11101
+ // ACC10: which dispatches this process is running, so the copy of a frame that
11102
+ // arrives on the other stream doesn't run the turn a second time.
11103
+ claims = new DispatchClaims();
11061
11104
  constructor(opts) {
11062
11105
  this.config = opts.config;
11063
11106
  this.log = opts.log;
@@ -11101,7 +11144,9 @@ var CompanionSupervisor = class {
11101
11144
  if (ev.event === "assignments_changed") {
11102
11145
  if (this.refreshing && this.inFlightRefresh) await this.inFlightRefresh;
11103
11146
  await this.refreshAssignments();
11147
+ return;
11104
11148
  }
11149
+ if (ev.event === "message") await this.handleDeviceMessage(ev);
11105
11150
  },
11106
11151
  onAuthFailure: () => {
11107
11152
  this.log.error(
@@ -11612,17 +11657,75 @@ var CompanionSupervisor = class {
11612
11657
  async handleWorkspaceMessage(wr, ev) {
11613
11658
  if (ev.event !== "message") return;
11614
11659
  this.hub.recordEvent(wr.workspaceId);
11615
- let wire;
11660
+ const wire = this.parseFrame(ev);
11661
+ if (!wire) return;
11662
+ if (ev.id) wr.cursor.observe(ev.id);
11663
+ await this.deliverFrame(wr, ev.id, wire);
11664
+ }
11665
+ // ACC10: the device stream's frames — dispatch and cancel, addressed to this
11666
+ // device and authenticated as it. Same frames and the same routing as above;
11667
+ // what differs is that this stream keeps no cursor (it replays what is still
11668
+ // offered on every connect instead of resuming from a `Last-Event-ID`), so
11669
+ // nothing here observes or settles one.
11670
+ async handleDeviceMessage(ev) {
11671
+ const wire = this.parseFrame(ev);
11672
+ if (!wire) return;
11673
+ let wr = this.workspaces.get(wire.workspaceId);
11674
+ if (!wr) {
11675
+ if (this.refreshing && this.inFlightRefresh) await this.inFlightRefresh;
11676
+ await this.refreshAssignments();
11677
+ wr = this.workspaces.get(wire.workspaceId);
11678
+ if (!wr) {
11679
+ const payload = dispatchPayload(wire);
11680
+ if (payload && payload.deviceId === this.deviceId) {
11681
+ await this.reportUnrunnableDispatch(wire.workspaceId, payload);
11682
+ } else {
11683
+ this.log.debug(
11684
+ { workspaceId: wire.workspaceId, type: wire.type },
11685
+ "companion: device frame for a workspace this device does not run; ignoring"
11686
+ );
11687
+ }
11688
+ return;
11689
+ }
11690
+ }
11691
+ this.hub.recordEvent(wr.workspaceId);
11692
+ await this.deliverFrame(wr, "", wire);
11693
+ }
11694
+ parseFrame(ev) {
11616
11695
  try {
11617
- wire = JSON.parse(ev.data);
11696
+ return JSON.parse(ev.data);
11618
11697
  } catch (err) {
11619
11698
  this.log.debug(
11620
11699
  { err: err instanceof Error ? err.message : String(err) },
11621
11700
  "malformed SSE payload"
11622
11701
  );
11702
+ return null;
11703
+ }
11704
+ }
11705
+ // ACC10: the dedupe gate, and the only place that exists because there are two
11706
+ // streams. Both carry the same frame with the same dispatch id; the first copy
11707
+ // to arrive runs the turn, and the second waits for it and then settles its own
11708
+ // stream's cursor. Settling a duplicate earlier than that would advance the
11709
+ // low-water mark past an in-flight turn, which is exactly the CT282 bug.
11710
+ //
11711
+ // A cancel needs no gate: `dispatcher.cancel` on an already-aborted run is a
11712
+ // no-op that reports `false`, so a second copy is inert.
11713
+ async deliverFrame(wr, cursorId, wire) {
11714
+ const claim = wire.type === "device:dispatch_requested" && wire.id ? this.claims.claim(wire.id) : null;
11715
+ if (claim && !claim.first) {
11716
+ await claim.done;
11717
+ if (cursorId) wr.cursor.settle(cursorId);
11623
11718
  return;
11624
11719
  }
11625
- if (ev.id) wr.cursor.observe(ev.id);
11720
+ try {
11721
+ await this.routeFrame(wr, cursorId, wire);
11722
+ } finally {
11723
+ if (claim) this.claims.release(wire.id);
11724
+ }
11725
+ }
11726
+ // Everything below here is the routing as it was: it takes a frame and knows
11727
+ // nothing about which stream carried it.
11728
+ async routeFrame(wr, cursorId, wire) {
11626
11729
  if (wire.type === "device:cancel_requested") {
11627
11730
  const payload2 = {
11628
11731
  type: "device:cancel_requested",
@@ -11642,17 +11745,14 @@ var CompanionSupervisor = class {
11642
11745
  );
11643
11746
  }
11644
11747
  }
11645
- if (ev.id) wr.cursor.settle(ev.id);
11748
+ if (cursorId) wr.cursor.settle(cursorId);
11646
11749
  return;
11647
11750
  }
11648
- if (wire.type !== "device:dispatch_requested") {
11649
- if (ev.id) wr.cursor.settle(ev.id);
11751
+ const payload = dispatchPayload(wire);
11752
+ if (!payload) {
11753
+ if (cursorId) wr.cursor.settle(cursorId);
11650
11754
  return;
11651
11755
  }
11652
- const payload = {
11653
- type: "device:dispatch_requested",
11654
- ...wire.payload
11655
- };
11656
11756
  if (this.deviceId && payload.deviceId && payload.deviceId !== this.deviceId) {
11657
11757
  this.log.debug(
11658
11758
  {
@@ -11663,7 +11763,7 @@ var CompanionSupervisor = class {
11663
11763
  },
11664
11764
  "companion: dispatch addressed to another device; ignoring"
11665
11765
  );
11666
- if (ev.id) wr.cursor.settle(ev.id);
11766
+ if (cursorId) wr.cursor.settle(cursorId);
11667
11767
  return;
11668
11768
  }
11669
11769
  let agent = wr.agents.get(payload.agentId);
@@ -11671,16 +11771,16 @@ var CompanionSupervisor = class {
11671
11771
  agent = await this.recoverRacedAgent(wr, payload);
11672
11772
  if (!agent) {
11673
11773
  if (this.deviceId && payload.deviceId === this.deviceId) {
11674
- const reported = await this.reportUnrunnableDispatch(wr, payload);
11774
+ const reported = await this.reportUnrunnableDispatch(wr.workspaceId, payload);
11675
11775
  if (!reported) return;
11676
11776
  }
11677
- if (ev.id) wr.cursor.settle(ev.id);
11777
+ if (cursorId) wr.cursor.settle(cursorId);
11678
11778
  return;
11679
11779
  }
11680
11780
  }
11681
11781
  const chainKey = `${payload.conversationId}|${payload.agentId}`;
11682
11782
  const prev = wr.chains.get(chainKey) ?? Promise.resolve();
11683
- const tail = prev.then(() => this.runDispatch(wr, ev, payload, agent)).catch((err) => {
11783
+ const tail = prev.then(() => this.runDispatch(wr, { dispatchId: wire.id, cursorId }, payload, agent)).catch((err) => {
11684
11784
  this.log.warn(
11685
11785
  {
11686
11786
  workspaceId: wr.workspaceId,
@@ -11707,7 +11807,7 @@ var CompanionSupervisor = class {
11707
11807
  // dropped socket) and one more attempt a second later usually lands. Bounded
11708
11808
  // hard — this runs inline on the event loop, and an unbounded wait here would
11709
11809
  // wedge the workspace's whole stream.
11710
- async reportUnrunnableDispatch(wr, payload) {
11810
+ async reportUnrunnableDispatch(workspaceId, payload) {
11711
11811
  if (!this.deviceApi) return false;
11712
11812
  let lastErr;
11713
11813
  for (const wait of this.unrunnableRetryDelaysMs) {
@@ -11715,7 +11815,7 @@ var CompanionSupervisor = class {
11715
11815
  if (wait > 0) await new Promise((r) => setTimeout(r, wait));
11716
11816
  try {
11717
11817
  const { cleared } = await this.deviceApi.reportDispatchDrop({
11718
- workspaceId: wr.workspaceId,
11818
+ workspaceId,
11719
11819
  conversationId: payload.conversationId,
11720
11820
  agentId: payload.agentId,
11721
11821
  messageId: payload.messageId,
@@ -11723,7 +11823,7 @@ var CompanionSupervisor = class {
11723
11823
  });
11724
11824
  this.log.debug(
11725
11825
  {
11726
- workspaceId: wr.workspaceId,
11826
+ workspaceId,
11727
11827
  conversationId: payload.conversationId,
11728
11828
  agentId: payload.agentId,
11729
11829
  cleared
@@ -11737,7 +11837,7 @@ var CompanionSupervisor = class {
11737
11837
  }
11738
11838
  this.log.error(
11739
11839
  {
11740
- workspaceId: wr.workspaceId,
11840
+ workspaceId,
11741
11841
  conversationId: payload.conversationId,
11742
11842
  agentId: payload.agentId,
11743
11843
  err: lastErr instanceof Error ? lastErr.message : String(lastErr)
@@ -11792,7 +11892,10 @@ var CompanionSupervisor = class {
11792
11892
  };
11793
11893
  const tail = this.runDispatch(
11794
11894
  wr,
11795
- { id: "", event: "recovery", data: "" },
11895
+ // No frame delivered this one — it came off `/runs` — so there is
11896
+ // neither a dispatch id to dedupe on nor a cursor to settle. The
11897
+ // `recoveredTurns` set above is this path's own guard.
11898
+ { dispatchId: "", cursorId: "" },
11796
11899
  payload,
11797
11900
  agent,
11798
11901
  run.turnId
@@ -11816,56 +11919,59 @@ var CompanionSupervisor = class {
11816
11919
  // One turn: the SJ516 F7 / CT26 skip-vs-resume dedupe, the dispatch, then
11817
11920
  // cursor + console summary. Mirrors the old per-workspace `handleEvent`, now
11818
11921
  // scoped to one agent.
11819
- async runDispatch(wr, ev, payload, agent, recoveredTurnId) {
11922
+ async runDispatch(wr, ids, payload, agent, recoveredTurnId) {
11820
11923
  const workspaceId = wr.workspaceId;
11821
- if (ev.id && hasDispatched(workspaceId, ev.id)) {
11822
- if (hasCompleted(workspaceId, ev.id)) {
11924
+ const settleCursor = () => {
11925
+ if (ids.cursorId) wr.cursor.settle(ids.cursorId);
11926
+ };
11927
+ if (ids.dispatchId && hasDispatched(workspaceId, ids.dispatchId)) {
11928
+ if (hasCompleted(workspaceId, ids.dispatchId)) {
11823
11929
  this.log.debug(
11824
- { workspaceId, eventId: ev.id },
11930
+ { workspaceId, eventId: ids.dispatchId },
11825
11931
  "companion: skipping already-completed event (resume after restart)"
11826
11932
  );
11827
- wr.cursor.settle(ev.id);
11933
+ settleCursor();
11828
11934
  return;
11829
11935
  }
11830
11936
  if (noResume()) {
11831
11937
  this.log.debug(
11832
- { workspaceId, eventId: ev.id },
11938
+ { workspaceId, eventId: ids.dispatchId },
11833
11939
  "companion: skipping interrupted turn (CABANE_COMPANION_NO_RESUME=1) \u2014 resume disabled for this boot"
11834
11940
  );
11835
- markCompleted(workspaceId, ev.id);
11836
- wr.cursor.settle(ev.id);
11941
+ markCompleted(workspaceId, ids.dispatchId);
11942
+ settleCursor();
11837
11943
  return;
11838
11944
  }
11839
- const attempt = bumpResumeAttempt(workspaceId, ev.id);
11945
+ const attempt = bumpResumeAttempt(workspaceId, ids.dispatchId);
11840
11946
  if (attempt > MAX_RESUME_ATTEMPTS) {
11841
11947
  this.log.error(
11842
- { workspaceId, eventId: ev.id, attempt, cap: MAX_RESUME_ATTEMPTS },
11948
+ { workspaceId, eventId: ids.dispatchId, attempt, cap: MAX_RESUME_ATTEMPTS },
11843
11949
  "An interrupted turn couldn't finish after several attempts. Reply in Cabane to try again."
11844
11950
  );
11845
- markCompleted(workspaceId, ev.id);
11846
- wr.cursor.settle(ev.id);
11951
+ markCompleted(workspaceId, ids.dispatchId);
11952
+ settleCursor();
11847
11953
  return;
11848
11954
  }
11849
11955
  this.log.debug(
11850
- { workspaceId, eventId: ev.id, attempt, cap: MAX_RESUME_ATTEMPTS },
11956
+ { workspaceId, eventId: ids.dispatchId, attempt, cap: MAX_RESUME_ATTEMPTS },
11851
11957
  "companion: re-dispatching interrupted turn (resume after restart)"
11852
11958
  );
11853
11959
  }
11854
- const resumedTurnId = recoveredTurnId ?? (ev.id ? turnIdForEvent(workspaceId, ev.id) : null);
11960
+ const resumedTurnId = recoveredTurnId ?? (ids.dispatchId ? turnIdForEvent(workspaceId, ids.dispatchId) : null);
11855
11961
  const turnId = resumedTurnId ?? randomUUID4();
11856
- if (ev.id) {
11857
- const liveTurnIds = rememberTurnId(workspaceId, ev.id, turnId);
11962
+ if (ids.dispatchId) {
11963
+ const liveTurnIds = rememberTurnId(workspaceId, ids.dispatchId, turnId);
11858
11964
  if (liveTurnIds > TURN_ID_OVERFLOW_WARN) {
11859
11965
  this.log.debug(
11860
11966
  { workspaceId, liveTurnIds, threshold: TURN_ID_OVERFLOW_WARN },
11861
11967
  "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
11968
  );
11863
11969
  }
11864
- markDispatched(workspaceId, ev.id);
11970
+ markDispatched(workspaceId, ids.dispatchId);
11865
11971
  }
11866
11972
  if (resumedTurnId) {
11867
11973
  this.log.debug(
11868
- { workspaceId, eventId: ev.id, turnId },
11974
+ { workspaceId, eventId: ids.dispatchId, turnId },
11869
11975
  "companion: resuming an interrupted turn under its original id"
11870
11976
  );
11871
11977
  }
@@ -11880,8 +11986,8 @@ var CompanionSupervisor = class {
11880
11986
  turnId,
11881
11987
  resumed: resumedTurnId !== null
11882
11988
  });
11883
- if (ev.id) markCompleted(workspaceId, ev.id);
11884
- if (ev.id) wr.cursor.settle(ev.id);
11989
+ if (ids.dispatchId) markCompleted(workspaceId, ids.dispatchId);
11990
+ settleCursor();
11885
11991
  const durationS = (result.durationMs / 1e3).toFixed(1);
11886
11992
  const bindings = {
11887
11993
  ...turnBindings,
package/dist/runtime.js CHANGED
@@ -3861,8 +3861,7 @@ var TASK_STAGES = [
3861
3861
  "in_progress",
3862
3862
  "in_review",
3863
3863
  "done",
3864
- "dropped",
3865
- "deferred"
3864
+ "dropped"
3866
3865
  ];
3867
3866
  var MIN_ASK_OPTIONS = 2;
3868
3867
  var MAX_ASK_OPTIONS = 4;
@@ -10077,6 +10076,41 @@ async function enumerateOpencodeModels(serverUrl, fetchImpl = fetch) {
10077
10076
  }
10078
10077
  }
10079
10078
 
10079
+ // src/dispatch-claims.ts
10080
+ var DispatchClaims = class {
10081
+ inFlight = /* @__PURE__ */ new Map();
10082
+ releases = /* @__PURE__ */ new Map();
10083
+ // Claim a dispatch id for this process. Synchronous by design: two copies can
10084
+ // arrive in the same tick, and a claim taken after an `await` would let both
10085
+ // through.
10086
+ claim(dispatchId) {
10087
+ const running = this.inFlight.get(dispatchId);
10088
+ if (running) return { first: false, done: running };
10089
+ let release;
10090
+ this.inFlight.set(
10091
+ dispatchId,
10092
+ new Promise((resolve2) => {
10093
+ release = resolve2;
10094
+ })
10095
+ );
10096
+ this.releases.set(dispatchId, release);
10097
+ return { first: true };
10098
+ }
10099
+ // Hand the claim back once the run has finished — whatever its outcome.
10100
+ // Releasing an id that isn't claimed is a no-op.
10101
+ release(dispatchId) {
10102
+ const resolve2 = this.releases.get(dispatchId);
10103
+ if (!resolve2) return;
10104
+ this.releases.delete(dispatchId);
10105
+ this.inFlight.delete(dispatchId);
10106
+ resolve2();
10107
+ }
10108
+ // The live set's size — the concurrent-turn count.
10109
+ get size() {
10110
+ return this.inFlight.size;
10111
+ }
10112
+ };
10113
+
10080
10114
  // src/outbox.ts
10081
10115
  import {
10082
10116
  existsSync as existsSync12,
@@ -10355,8 +10389,7 @@ var SseSubscriber = class {
10355
10389
  }
10356
10390
  async connect() {
10357
10391
  const base = this.opts.baseUrl.endsWith("/") ? this.opts.baseUrl.slice(0, -1) : this.opts.baseUrl;
10358
- const query = this.opts.path ? "" : this.lastEventId ? "" : "?replayPending=1";
10359
- const url = this.opts.path ? `${base}${this.opts.path}` : `${base}/api/workspaces/${this.opts.workspaceId}/events${query}`;
10392
+ const url = this.opts.path ? `${base}${this.opts.path}` : `${base}/api/workspaces/${this.opts.workspaceId}/events`;
10360
10393
  this.controller = new AbortController();
10361
10394
  const headers = {
10362
10395
  Authorization: `Bearer ${this.opts.token}`,
@@ -10423,6 +10456,13 @@ var DRAIN_BASE_MS = 1e3;
10423
10456
  var DRAIN_MAX_MS = 3e4;
10424
10457
  var DRAIN_IDLE_MS = 15e3;
10425
10458
  var DEFAULT_UNRUNNABLE_RETRY_DELAYS_MS = [0, 1e3, 3e3];
10459
+ function dispatchPayload(wire) {
10460
+ if (wire.type !== "device:dispatch_requested") return null;
10461
+ return {
10462
+ type: "device:dispatch_requested",
10463
+ ...wire.payload
10464
+ };
10465
+ }
10426
10466
  var CompanionSupervisor = class {
10427
10467
  workspaces = /* @__PURE__ */ new Map();
10428
10468
  config;
@@ -10481,6 +10521,9 @@ var CompanionSupervisor = class {
10481
10521
  // turn's classified outcome here (per runtime); the heartbeat echoes it so the
10482
10522
  // server's per-connector quota state survives a restart/race.
10483
10523
  connectorHealth = new ConnectorHealthStore();
10524
+ // ACC10: which dispatches this process is running, so the copy of a frame that
10525
+ // arrives on the other stream doesn't run the turn a second time.
10526
+ claims = new DispatchClaims();
10484
10527
  constructor(opts) {
10485
10528
  this.config = opts.config;
10486
10529
  this.log = opts.log;
@@ -10524,7 +10567,9 @@ var CompanionSupervisor = class {
10524
10567
  if (ev.event === "assignments_changed") {
10525
10568
  if (this.refreshing && this.inFlightRefresh) await this.inFlightRefresh;
10526
10569
  await this.refreshAssignments();
10570
+ return;
10527
10571
  }
10572
+ if (ev.event === "message") await this.handleDeviceMessage(ev);
10528
10573
  },
10529
10574
  onAuthFailure: () => {
10530
10575
  this.log.error(
@@ -11035,17 +11080,75 @@ var CompanionSupervisor = class {
11035
11080
  async handleWorkspaceMessage(wr, ev) {
11036
11081
  if (ev.event !== "message") return;
11037
11082
  this.hub.recordEvent(wr.workspaceId);
11038
- let wire;
11083
+ const wire = this.parseFrame(ev);
11084
+ if (!wire) return;
11085
+ if (ev.id) wr.cursor.observe(ev.id);
11086
+ await this.deliverFrame(wr, ev.id, wire);
11087
+ }
11088
+ // ACC10: the device stream's frames — dispatch and cancel, addressed to this
11089
+ // device and authenticated as it. Same frames and the same routing as above;
11090
+ // what differs is that this stream keeps no cursor (it replays what is still
11091
+ // offered on every connect instead of resuming from a `Last-Event-ID`), so
11092
+ // nothing here observes or settles one.
11093
+ async handleDeviceMessage(ev) {
11094
+ const wire = this.parseFrame(ev);
11095
+ if (!wire) return;
11096
+ let wr = this.workspaces.get(wire.workspaceId);
11097
+ if (!wr) {
11098
+ if (this.refreshing && this.inFlightRefresh) await this.inFlightRefresh;
11099
+ await this.refreshAssignments();
11100
+ wr = this.workspaces.get(wire.workspaceId);
11101
+ if (!wr) {
11102
+ const payload = dispatchPayload(wire);
11103
+ if (payload && payload.deviceId === this.deviceId) {
11104
+ await this.reportUnrunnableDispatch(wire.workspaceId, payload);
11105
+ } else {
11106
+ this.log.debug(
11107
+ { workspaceId: wire.workspaceId, type: wire.type },
11108
+ "companion: device frame for a workspace this device does not run; ignoring"
11109
+ );
11110
+ }
11111
+ return;
11112
+ }
11113
+ }
11114
+ this.hub.recordEvent(wr.workspaceId);
11115
+ await this.deliverFrame(wr, "", wire);
11116
+ }
11117
+ parseFrame(ev) {
11039
11118
  try {
11040
- wire = JSON.parse(ev.data);
11119
+ return JSON.parse(ev.data);
11041
11120
  } catch (err) {
11042
11121
  this.log.debug(
11043
11122
  { err: err instanceof Error ? err.message : String(err) },
11044
11123
  "malformed SSE payload"
11045
11124
  );
11125
+ return null;
11126
+ }
11127
+ }
11128
+ // ACC10: the dedupe gate, and the only place that exists because there are two
11129
+ // streams. Both carry the same frame with the same dispatch id; the first copy
11130
+ // to arrive runs the turn, and the second waits for it and then settles its own
11131
+ // stream's cursor. Settling a duplicate earlier than that would advance the
11132
+ // low-water mark past an in-flight turn, which is exactly the CT282 bug.
11133
+ //
11134
+ // A cancel needs no gate: `dispatcher.cancel` on an already-aborted run is a
11135
+ // no-op that reports `false`, so a second copy is inert.
11136
+ async deliverFrame(wr, cursorId, wire) {
11137
+ const claim = wire.type === "device:dispatch_requested" && wire.id ? this.claims.claim(wire.id) : null;
11138
+ if (claim && !claim.first) {
11139
+ await claim.done;
11140
+ if (cursorId) wr.cursor.settle(cursorId);
11046
11141
  return;
11047
11142
  }
11048
- if (ev.id) wr.cursor.observe(ev.id);
11143
+ try {
11144
+ await this.routeFrame(wr, cursorId, wire);
11145
+ } finally {
11146
+ if (claim) this.claims.release(wire.id);
11147
+ }
11148
+ }
11149
+ // Everything below here is the routing as it was: it takes a frame and knows
11150
+ // nothing about which stream carried it.
11151
+ async routeFrame(wr, cursorId, wire) {
11049
11152
  if (wire.type === "device:cancel_requested") {
11050
11153
  const payload2 = {
11051
11154
  type: "device:cancel_requested",
@@ -11065,17 +11168,14 @@ var CompanionSupervisor = class {
11065
11168
  );
11066
11169
  }
11067
11170
  }
11068
- if (ev.id) wr.cursor.settle(ev.id);
11171
+ if (cursorId) wr.cursor.settle(cursorId);
11069
11172
  return;
11070
11173
  }
11071
- if (wire.type !== "device:dispatch_requested") {
11072
- if (ev.id) wr.cursor.settle(ev.id);
11174
+ const payload = dispatchPayload(wire);
11175
+ if (!payload) {
11176
+ if (cursorId) wr.cursor.settle(cursorId);
11073
11177
  return;
11074
11178
  }
11075
- const payload = {
11076
- type: "device:dispatch_requested",
11077
- ...wire.payload
11078
- };
11079
11179
  if (this.deviceId && payload.deviceId && payload.deviceId !== this.deviceId) {
11080
11180
  this.log.debug(
11081
11181
  {
@@ -11086,7 +11186,7 @@ var CompanionSupervisor = class {
11086
11186
  },
11087
11187
  "companion: dispatch addressed to another device; ignoring"
11088
11188
  );
11089
- if (ev.id) wr.cursor.settle(ev.id);
11189
+ if (cursorId) wr.cursor.settle(cursorId);
11090
11190
  return;
11091
11191
  }
11092
11192
  let agent = wr.agents.get(payload.agentId);
@@ -11094,16 +11194,16 @@ var CompanionSupervisor = class {
11094
11194
  agent = await this.recoverRacedAgent(wr, payload);
11095
11195
  if (!agent) {
11096
11196
  if (this.deviceId && payload.deviceId === this.deviceId) {
11097
- const reported = await this.reportUnrunnableDispatch(wr, payload);
11197
+ const reported = await this.reportUnrunnableDispatch(wr.workspaceId, payload);
11098
11198
  if (!reported) return;
11099
11199
  }
11100
- if (ev.id) wr.cursor.settle(ev.id);
11200
+ if (cursorId) wr.cursor.settle(cursorId);
11101
11201
  return;
11102
11202
  }
11103
11203
  }
11104
11204
  const chainKey = `${payload.conversationId}|${payload.agentId}`;
11105
11205
  const prev = wr.chains.get(chainKey) ?? Promise.resolve();
11106
- const tail = prev.then(() => this.runDispatch(wr, ev, payload, agent)).catch((err) => {
11206
+ const tail = prev.then(() => this.runDispatch(wr, { dispatchId: wire.id, cursorId }, payload, agent)).catch((err) => {
11107
11207
  this.log.warn(
11108
11208
  {
11109
11209
  workspaceId: wr.workspaceId,
@@ -11130,7 +11230,7 @@ var CompanionSupervisor = class {
11130
11230
  // dropped socket) and one more attempt a second later usually lands. Bounded
11131
11231
  // hard — this runs inline on the event loop, and an unbounded wait here would
11132
11232
  // wedge the workspace's whole stream.
11133
- async reportUnrunnableDispatch(wr, payload) {
11233
+ async reportUnrunnableDispatch(workspaceId, payload) {
11134
11234
  if (!this.deviceApi) return false;
11135
11235
  let lastErr;
11136
11236
  for (const wait of this.unrunnableRetryDelaysMs) {
@@ -11138,7 +11238,7 @@ var CompanionSupervisor = class {
11138
11238
  if (wait > 0) await new Promise((r) => setTimeout(r, wait));
11139
11239
  try {
11140
11240
  const { cleared } = await this.deviceApi.reportDispatchDrop({
11141
- workspaceId: wr.workspaceId,
11241
+ workspaceId,
11142
11242
  conversationId: payload.conversationId,
11143
11243
  agentId: payload.agentId,
11144
11244
  messageId: payload.messageId,
@@ -11146,7 +11246,7 @@ var CompanionSupervisor = class {
11146
11246
  });
11147
11247
  this.log.debug(
11148
11248
  {
11149
- workspaceId: wr.workspaceId,
11249
+ workspaceId,
11150
11250
  conversationId: payload.conversationId,
11151
11251
  agentId: payload.agentId,
11152
11252
  cleared
@@ -11160,7 +11260,7 @@ var CompanionSupervisor = class {
11160
11260
  }
11161
11261
  this.log.error(
11162
11262
  {
11163
- workspaceId: wr.workspaceId,
11263
+ workspaceId,
11164
11264
  conversationId: payload.conversationId,
11165
11265
  agentId: payload.agentId,
11166
11266
  err: lastErr instanceof Error ? lastErr.message : String(lastErr)
@@ -11215,7 +11315,10 @@ var CompanionSupervisor = class {
11215
11315
  };
11216
11316
  const tail = this.runDispatch(
11217
11317
  wr,
11218
- { id: "", event: "recovery", data: "" },
11318
+ // No frame delivered this one — it came off `/runs` — so there is
11319
+ // neither a dispatch id to dedupe on nor a cursor to settle. The
11320
+ // `recoveredTurns` set above is this path's own guard.
11321
+ { dispatchId: "", cursorId: "" },
11219
11322
  payload,
11220
11323
  agent,
11221
11324
  run.turnId
@@ -11239,56 +11342,59 @@ var CompanionSupervisor = class {
11239
11342
  // One turn: the SJ516 F7 / CT26 skip-vs-resume dedupe, the dispatch, then
11240
11343
  // cursor + console summary. Mirrors the old per-workspace `handleEvent`, now
11241
11344
  // scoped to one agent.
11242
- async runDispatch(wr, ev, payload, agent, recoveredTurnId) {
11345
+ async runDispatch(wr, ids, payload, agent, recoveredTurnId) {
11243
11346
  const workspaceId = wr.workspaceId;
11244
- if (ev.id && hasDispatched(workspaceId, ev.id)) {
11245
- if (hasCompleted(workspaceId, ev.id)) {
11347
+ const settleCursor = () => {
11348
+ if (ids.cursorId) wr.cursor.settle(ids.cursorId);
11349
+ };
11350
+ if (ids.dispatchId && hasDispatched(workspaceId, ids.dispatchId)) {
11351
+ if (hasCompleted(workspaceId, ids.dispatchId)) {
11246
11352
  this.log.debug(
11247
- { workspaceId, eventId: ev.id },
11353
+ { workspaceId, eventId: ids.dispatchId },
11248
11354
  "companion: skipping already-completed event (resume after restart)"
11249
11355
  );
11250
- wr.cursor.settle(ev.id);
11356
+ settleCursor();
11251
11357
  return;
11252
11358
  }
11253
11359
  if (noResume()) {
11254
11360
  this.log.debug(
11255
- { workspaceId, eventId: ev.id },
11361
+ { workspaceId, eventId: ids.dispatchId },
11256
11362
  "companion: skipping interrupted turn (CABANE_COMPANION_NO_RESUME=1) \u2014 resume disabled for this boot"
11257
11363
  );
11258
- markCompleted(workspaceId, ev.id);
11259
- wr.cursor.settle(ev.id);
11364
+ markCompleted(workspaceId, ids.dispatchId);
11365
+ settleCursor();
11260
11366
  return;
11261
11367
  }
11262
- const attempt = bumpResumeAttempt(workspaceId, ev.id);
11368
+ const attempt = bumpResumeAttempt(workspaceId, ids.dispatchId);
11263
11369
  if (attempt > MAX_RESUME_ATTEMPTS) {
11264
11370
  this.log.error(
11265
- { workspaceId, eventId: ev.id, attempt, cap: MAX_RESUME_ATTEMPTS },
11371
+ { workspaceId, eventId: ids.dispatchId, attempt, cap: MAX_RESUME_ATTEMPTS },
11266
11372
  "An interrupted turn couldn't finish after several attempts. Reply in Cabane to try again."
11267
11373
  );
11268
- markCompleted(workspaceId, ev.id);
11269
- wr.cursor.settle(ev.id);
11374
+ markCompleted(workspaceId, ids.dispatchId);
11375
+ settleCursor();
11270
11376
  return;
11271
11377
  }
11272
11378
  this.log.debug(
11273
- { workspaceId, eventId: ev.id, attempt, cap: MAX_RESUME_ATTEMPTS },
11379
+ { workspaceId, eventId: ids.dispatchId, attempt, cap: MAX_RESUME_ATTEMPTS },
11274
11380
  "companion: re-dispatching interrupted turn (resume after restart)"
11275
11381
  );
11276
11382
  }
11277
- const resumedTurnId = recoveredTurnId ?? (ev.id ? turnIdForEvent(workspaceId, ev.id) : null);
11383
+ const resumedTurnId = recoveredTurnId ?? (ids.dispatchId ? turnIdForEvent(workspaceId, ids.dispatchId) : null);
11278
11384
  const turnId = resumedTurnId ?? randomUUID4();
11279
- if (ev.id) {
11280
- const liveTurnIds = rememberTurnId(workspaceId, ev.id, turnId);
11385
+ if (ids.dispatchId) {
11386
+ const liveTurnIds = rememberTurnId(workspaceId, ids.dispatchId, turnId);
11281
11387
  if (liveTurnIds > TURN_ID_OVERFLOW_WARN) {
11282
11388
  this.log.debug(
11283
11389
  { workspaceId, liveTurnIds, threshold: TURN_ID_OVERFLOW_WARN },
11284
11390
  "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
11391
  );
11286
11392
  }
11287
- markDispatched(workspaceId, ev.id);
11393
+ markDispatched(workspaceId, ids.dispatchId);
11288
11394
  }
11289
11395
  if (resumedTurnId) {
11290
11396
  this.log.debug(
11291
- { workspaceId, eventId: ev.id, turnId },
11397
+ { workspaceId, eventId: ids.dispatchId, turnId },
11292
11398
  "companion: resuming an interrupted turn under its original id"
11293
11399
  );
11294
11400
  }
@@ -11303,8 +11409,8 @@ var CompanionSupervisor = class {
11303
11409
  turnId,
11304
11410
  resumed: resumedTurnId !== null
11305
11411
  });
11306
- if (ev.id) markCompleted(workspaceId, ev.id);
11307
- if (ev.id) wr.cursor.settle(ev.id);
11412
+ if (ids.dispatchId) markCompleted(workspaceId, ids.dispatchId);
11413
+ settleCursor();
11308
11414
  const durationS = (result.durationMs / 1e3).toFixed(1);
11309
11415
  const bindings = {
11310
11416
  ...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.114",
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",