@cabane/companion 0.6.63 → 0.6.64

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 +165 -23
  2. package/dist/runtime.js +165 -23
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -1735,7 +1735,7 @@ function getLogger() {
1735
1735
  }
1736
1736
 
1737
1737
  // src/runtime.ts
1738
- import { randomUUID as randomUUID2 } from "crypto";
1738
+ import { randomUUID as randomUUID3 } from "crypto";
1739
1739
 
1740
1740
  // src/dashboard/server.ts
1741
1741
  import { dirname as dirname4, join as join7 } from "path";
@@ -2278,6 +2278,9 @@ async function warnAboutHarnessReadiness(cfg, deps = {}) {
2278
2278
  );
2279
2279
  }
2280
2280
 
2281
+ // src/supervisor.ts
2282
+ import { randomUUID as randomUUID2 } from "crypto";
2283
+
2281
2284
  // src/api.ts
2282
2285
  var RETRY_BACKOFF_MS = [250, 750];
2283
2286
  var ACTIVE_RUN_OUTBOX_SEQ = 0;
@@ -2522,12 +2525,20 @@ var CabaneApi = class {
2522
2525
  // `durableActiveRunWrite`). The session-id-only write (first-frame capture) is
2523
2526
  // left best-effort: it's lower-stakes and self-heals on the next turn, so it
2524
2527
  // stays a single-shot PATCH and is deliberately out of CT93's scope.
2525
- setActiveRun(workspaceId, conversationId, agentId, body) {
2528
+ async setActiveRun(workspaceId, conversationId, agentId, body) {
2526
2529
  const path = `/api/workspaces/${workspaceId}/conversations/${conversationId}/participants/agents/${agentId}/active-run`;
2527
2530
  const touchesFlag = Object.prototype.hasOwnProperty.call(body, "activeRunStartedAt");
2528
- if (touchesFlag && this.opts.outbox) {
2531
+ const startsRun = touchesFlag && body.activeRunStartedAt !== null && body.activeRunStartedAt !== void 0;
2532
+ if (touchesFlag && !startsRun && this.opts.outbox) {
2529
2533
  return this.durableActiveRunWrite(path, conversationId, agentId, body);
2530
2534
  }
2535
+ if (startsRun) {
2536
+ const res = await this.request("PATCH", path, body, {
2537
+ retry: true
2538
+ });
2539
+ this.opts.outbox?.remove(activeRunOutboxKey(conversationId, agentId), ACTIVE_RUN_OUTBOX_SEQ);
2540
+ return res;
2541
+ }
2531
2542
  return this.request("PATCH", path, body);
2532
2543
  }
2533
2544
  // CT93: send-or-enqueue for an active-run flag write, with last-writer-wins
@@ -2819,7 +2830,7 @@ var CursorTracker = class {
2819
2830
  };
2820
2831
 
2821
2832
  // src/dispatch-dedupe.ts
2822
- import { mkdirSync as mkdirSync7, readFileSync as readFileSync5, writeFileSync as writeFileSync5, existsSync as existsSync7 } from "fs";
2833
+ import { mkdirSync as mkdirSync7, readFileSync as readFileSync5, writeFileSync as writeFileSync5, renameSync as renameSync3, existsSync as existsSync7 } from "fs";
2823
2834
  import { join as join9 } from "path";
2824
2835
  var MAX_IDS = 256;
2825
2836
  function dir(log) {
@@ -2859,6 +2870,7 @@ function hasCompleted(workspaceId, eventId) {
2859
2870
  }
2860
2871
  function markCompleted(workspaceId, eventId) {
2861
2872
  mark("completed", workspaceId, eventId);
2873
+ forgetTurnId(workspaceId, eventId);
2862
2874
  }
2863
2875
  var MAX_RESUME_ATTEMPTS = 3;
2864
2876
  function resumeDir() {
@@ -2900,6 +2912,56 @@ function bumpResumeAttempt(workspaceId, eventId) {
2900
2912
  );
2901
2913
  return next;
2902
2914
  }
2915
+ function turnDir() {
2916
+ return join9(cabaneDir(), "turns");
2917
+ }
2918
+ function turnPathFor(workspaceId) {
2919
+ return join9(turnDir(), encodeURIComponent(workspaceId));
2920
+ }
2921
+ var TURN_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
2922
+ function readTurnIds(workspaceId) {
2923
+ const out = /* @__PURE__ */ new Map();
2924
+ const path = turnPathFor(workspaceId);
2925
+ if (!existsSync7(path)) return out;
2926
+ try {
2927
+ for (const line of readFileSync5(path, "utf8").split("\n")) {
2928
+ const trimmed = line.trim();
2929
+ if (!trimmed) continue;
2930
+ const tab = trimmed.lastIndexOf(" ");
2931
+ if (tab <= 0) continue;
2932
+ const id = trimmed.slice(0, tab);
2933
+ const turnId = trimmed.slice(tab + 1);
2934
+ if (id && TURN_ID_RE.test(turnId)) out.set(id, turnId);
2935
+ }
2936
+ } catch {
2937
+ return out;
2938
+ }
2939
+ return out;
2940
+ }
2941
+ function turnIdForEvent(workspaceId, eventId) {
2942
+ return readTurnIds(workspaceId).get(eventId) ?? null;
2943
+ }
2944
+ var TURN_ID_OVERFLOW_WARN = 8192;
2945
+ function writeTurnIds(workspaceId, turns) {
2946
+ const entries = [...turns.entries()];
2947
+ mkdirSync7(turnDir(), { recursive: true });
2948
+ const path = turnPathFor(workspaceId);
2949
+ const tmp = `${path}.${process.pid}.tmp`;
2950
+ writeFileSync5(tmp, entries.map(([id, t]) => `${id} ${t}`).join("\n") + "\n", "utf8");
2951
+ renameSync3(tmp, path);
2952
+ return entries.length;
2953
+ }
2954
+ function rememberTurnId(workspaceId, eventId, turnId) {
2955
+ const turns = readTurnIds(workspaceId);
2956
+ if (turns.get(eventId) === turnId) return turns.size;
2957
+ turns.set(eventId, turnId);
2958
+ return writeTurnIds(workspaceId, turns);
2959
+ }
2960
+ function forgetTurnId(workspaceId, eventId) {
2961
+ const turns = readTurnIds(workspaceId);
2962
+ if (!turns.delete(eventId)) return;
2963
+ writeTurnIds(workspaceId, turns);
2964
+ }
2903
2965
  function noResume() {
2904
2966
  return process.env.CABANE_COMPANION_NO_RESUME === "1";
2905
2967
  }
@@ -8040,6 +8102,18 @@ function checkoutState(cwd) {
8040
8102
  function runKey(conversationId, agentId) {
8041
8103
  return `${conversationId}|${agentId}`;
8042
8104
  }
8105
+ var LEASE_REFUSALS = /* @__PURE__ */ new Set([
8106
+ "dispatch_not_admitted",
8107
+ "turn_already_ended",
8108
+ "turn_belongs_elsewhere"
8109
+ ]);
8110
+ function leaseRefusal(err) {
8111
+ if (!(err instanceof ApiError)) return null;
8112
+ const body = err.body;
8113
+ const code = typeof body === "object" && body !== null && typeof body.error === "string" ? body.error : null;
8114
+ if (code && LEASE_REFUSALS.has(code)) return code;
8115
+ return null;
8116
+ }
8043
8117
  var ERROR_BODY_LOG_CAP = 2e3;
8044
8118
  function describeErrorBody(body) {
8045
8119
  if (body === void 0 || body === null) return void 0;
@@ -8074,6 +8148,16 @@ var Dispatcher = class {
8074
8148
  }
8075
8149
  opts;
8076
8150
  // SJ383: per-(conversation, agent) abort registry.
8151
+ // CT1288: RunKey -> (turnId -> controller). This used to be one controller
8152
+ // per (conversation, agent), which quietly encoded the invariant the whole
8153
+ // task exists to enforce: that a pair can only ever have one live loop. When
8154
+ // that assumption broke, the second `set` EVICTED the first controller and
8155
+ // the first loop became permanently uncancellable — no other code path can
8156
+ // reach into a running turn. So the registry that Stop depends on failed
8157
+ // exactly when Stop was the thing you needed.
8158
+ //
8159
+ // Nesting by turn id costs nothing in the normal single-turn case and makes
8160
+ // `cancel` total: it aborts every loop under the pair, not the newest one.
8077
8161
  aborts;
8078
8162
  // CT1109: pairs already told, in the conversation, that their session state is
8079
8163
  // being refused. The failure repeats every single turn until someone fixes the
@@ -8136,7 +8220,12 @@ var Dispatcher = class {
8136
8220
  this.notifyEnd({ id: payload.messageId, ok: false, durationMs, reason });
8137
8221
  return { ok: false, durationMs, reason };
8138
8222
  }
8139
- async handle(payload) {
8223
+ // CT1288: `opts.turnId` is a RESUMED turn id — the id this event already ran
8224
+ // under before the companion died. Passing it makes the replay ask the
8225
+ // mailroom to re-admit the turn it already owns, which the server explicitly
8226
+ // supports ("a replay reports, it never re-points"). Omitted on a fresh
8227
+ // dispatch, where minting below is correct.
8228
+ async handle(payload, opts = {}) {
8140
8229
  const startedAt = Date.now();
8141
8230
  const dispatchId = payload.messageId;
8142
8231
  const workspaceId = this.opts.workspaceId;
@@ -8146,7 +8235,7 @@ var Dispatcher = class {
8146
8235
  agentId: payload.agentId,
8147
8236
  messageId: payload.messageId
8148
8237
  });
8149
- const turnId = randomUUID();
8238
+ const turnId = opts.turnId ?? randomUUID();
8150
8239
  let turnContext;
8151
8240
  try {
8152
8241
  turnContext = await this.opts.api.getTurnContext(
@@ -8368,7 +8457,18 @@ ${reason}`,
8368
8457
  };
8369
8458
  const key = runKey(payload.conversationId, payload.agentId);
8370
8459
  const abortController = new AbortController();
8371
- this.aborts.set(key, abortController);
8460
+ let pairAborts = this.aborts.get(key);
8461
+ if (!pairAborts) {
8462
+ pairAborts = /* @__PURE__ */ new Map();
8463
+ this.aborts.set(key, pairAborts);
8464
+ }
8465
+ pairAborts.set(turnId, abortController);
8466
+ const releaseAbort = () => {
8467
+ const pair2 = this.aborts.get(key);
8468
+ if (pair2?.get(turnId) !== abortController) return;
8469
+ pair2.delete(turnId);
8470
+ if (pair2.size === 0) this.aborts.delete(key);
8471
+ };
8372
8472
  let timeoutReason = null;
8373
8473
  try {
8374
8474
  await this.opts.api.setActiveRun(workspaceId, payload.conversationId, payload.agentId, {
@@ -8381,10 +8481,21 @@ ${reason}`,
8381
8481
  turnId
8382
8482
  });
8383
8483
  } catch (err) {
8384
- turnLog.warn(
8385
- { err: err instanceof Error ? err.message : String(err) },
8386
- "dispatcher: active-run flag set failed terminally; proceeding"
8484
+ const refusal = leaseRefusal(err);
8485
+ releaseAbort();
8486
+ if (refusal === "dispatch_not_admitted" || refusal === "turn_already_ended") {
8487
+ turnLog.error(
8488
+ { refusal, turnId },
8489
+ "dispatcher: refused a turn lease; not running the model"
8490
+ );
8491
+ return this.concludeBeforeRun(payload, turnLog, startedAt, `lease_refused: ${refusal}`);
8492
+ }
8493
+ const reason = refusal ? `lease_refused: ${refusal}` : "lease_unconfirmed";
8494
+ turnLog.error(
8495
+ { refusal, turnId, err: err instanceof Error ? err.message : String(err) },
8496
+ "dispatcher: turn lease not confirmed; not running the model"
8387
8497
  );
8498
+ return this.concludeBeforeRun(payload, turnLog, startedAt, reason, reason);
8388
8499
  }
8389
8500
  const sendState = createSendState();
8390
8501
  const skipState = createSkipState();
@@ -8488,6 +8599,7 @@ ${reason}`,
8488
8599
  "dispatcher: runtime-unavailable notice post failed"
8489
8600
  );
8490
8601
  }
8602
+ releaseAbort();
8491
8603
  return this.concludeBeforeRun(
8492
8604
  payload,
8493
8605
  turnLog,
@@ -8887,9 +8999,7 @@ ${reason}`,
8887
8999
  "dispatcher: active-run clear failed terminally; server age-sweep is the backstop"
8888
9000
  );
8889
9001
  }
8890
- if (this.aborts.get(key) === abortController) {
8891
- this.aborts.delete(key);
8892
- }
9002
+ releaseAbort();
8893
9003
  }
8894
9004
  const durationMs = Date.now() - startedAt;
8895
9005
  const finalReplyBody = committer.replyBody;
@@ -8933,11 +9043,13 @@ ${reason}`,
8933
9043
  // THIS companion process. Returns true if an in-flight run was aborted.
8934
9044
  cancel(conversationId, agentId) {
8935
9045
  const key = runKey(conversationId, agentId);
8936
- const ac = this.aborts.get(key);
8937
- if (!ac) return false;
8938
- try {
8939
- ac.abort();
8940
- } catch {
9046
+ const pair2 = this.aborts.get(key);
9047
+ if (!pair2 || pair2.size === 0) return false;
9048
+ for (const ac of [...pair2.values()]) {
9049
+ try {
9050
+ ac.abort();
9051
+ } catch {
9052
+ }
8941
9053
  }
8942
9054
  return true;
8943
9055
  }
@@ -9001,7 +9113,7 @@ import {
9001
9113
  mkdirSync as mkdirSync10,
9002
9114
  readdirSync as readdirSync3,
9003
9115
  readFileSync as readFileSync8,
9004
- renameSync as renameSync3,
9116
+ renameSync as renameSync4,
9005
9117
  rmSync as rmSync7,
9006
9118
  writeFileSync as writeFileSync7
9007
9119
  } from "fs";
@@ -9033,7 +9145,7 @@ var Outbox = class {
9033
9145
  const tmp = `${target}.${process.pid}.tmp`;
9034
9146
  try {
9035
9147
  writeFileSync7(tmp, JSON.stringify(entry) + "\n", "utf8");
9036
- renameSync3(tmp, target);
9148
+ renameSync4(tmp, target);
9037
9149
  } catch (err) {
9038
9150
  try {
9039
9151
  rmSync7(tmp, { force: true });
@@ -9834,6 +9946,19 @@ var CompanionSupervisor = class {
9834
9946
  type: "device:dispatch_requested",
9835
9947
  ...wire.payload
9836
9948
  };
9949
+ if (this.deviceId && payload.deviceId && payload.deviceId !== this.deviceId) {
9950
+ this.log.debug(
9951
+ {
9952
+ workspaceId: wr.workspaceId,
9953
+ conversationId: payload.conversationId,
9954
+ agentId: payload.agentId,
9955
+ addressedTo: payload.deviceId
9956
+ },
9957
+ "companion: dispatch addressed to another device; ignoring"
9958
+ );
9959
+ if (ev.id) wr.cursor.settle(ev.id);
9960
+ return;
9961
+ }
9837
9962
  let agent = wr.agents.get(payload.agentId);
9838
9963
  if (!agent) {
9839
9964
  agent = await this.recoverRacedAgent(wr, payload);
@@ -9965,8 +10090,25 @@ var CompanionSupervisor = class {
9965
10090
  "companion: re-dispatching interrupted turn (resume after restart)"
9966
10091
  );
9967
10092
  }
9968
- if (ev.id) markDispatched(workspaceId, ev.id);
9969
- const result = await agent.dispatcher.handle(payload);
10093
+ const resumedTurnId = ev.id ? turnIdForEvent(workspaceId, ev.id) : null;
10094
+ const turnId = resumedTurnId ?? randomUUID2();
10095
+ if (ev.id) {
10096
+ const liveTurnIds = rememberTurnId(workspaceId, ev.id, turnId);
10097
+ if (liveTurnIds > TURN_ID_OVERFLOW_WARN) {
10098
+ this.log.error(
10099
+ { workspaceId, liveTurnIds, threshold: TURN_ID_OVERFLOW_WARN },
10100
+ "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."
10101
+ );
10102
+ }
10103
+ markDispatched(workspaceId, ev.id);
10104
+ }
10105
+ if (resumedTurnId) {
10106
+ this.log.info(
10107
+ { workspaceId, eventId: ev.id, turnId },
10108
+ "companion: resuming an interrupted turn under its original id"
10109
+ );
10110
+ }
10111
+ const result = await agent.dispatcher.handle(payload, { turnId });
9970
10112
  if (ev.id) markCompleted(workspaceId, ev.id);
9971
10113
  if (ev.id) wr.cursor.settle(ev.id);
9972
10114
  const durationS = (result.durationMs / 1e3).toFixed(1);
@@ -10357,7 +10499,7 @@ async function createCompanionRuntime(opts = {}) {
10357
10499
  opencodeServerUrl: cfg.opencode?.serverUrl,
10358
10500
  codex: isCodexEnabled(cfg)
10359
10501
  });
10360
- const instanceId = randomUUID2();
10502
+ const instanceId = randomUUID3();
10361
10503
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
10362
10504
  const pre = readLiveRuntimeState();
10363
10505
  if (pre && await verifyRuntime(pre) === "stale") clearRuntimeState();
package/dist/runtime.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/runtime.ts
2
- import { randomUUID as randomUUID2 } from "crypto";
2
+ import { randomUUID as randomUUID3 } from "crypto";
3
3
 
4
4
  // src/config.ts
5
5
  import {
@@ -1698,6 +1698,9 @@ async function verifyRuntime(state, requestImpl = controlRequest) {
1698
1698
  return body.instance_id === state.instanceId ? "ours" : "stale";
1699
1699
  }
1700
1700
 
1701
+ // src/supervisor.ts
1702
+ import { randomUUID as randomUUID2 } from "crypto";
1703
+
1701
1704
  // src/api.ts
1702
1705
  var RETRY_BACKOFF_MS = [250, 750];
1703
1706
  var ACTIVE_RUN_OUTBOX_SEQ = 0;
@@ -1942,12 +1945,20 @@ var CabaneApi = class {
1942
1945
  // `durableActiveRunWrite`). The session-id-only write (first-frame capture) is
1943
1946
  // left best-effort: it's lower-stakes and self-heals on the next turn, so it
1944
1947
  // stays a single-shot PATCH and is deliberately out of CT93's scope.
1945
- setActiveRun(workspaceId, conversationId, agentId, body) {
1948
+ async setActiveRun(workspaceId, conversationId, agentId, body) {
1946
1949
  const path = `/api/workspaces/${workspaceId}/conversations/${conversationId}/participants/agents/${agentId}/active-run`;
1947
1950
  const touchesFlag = Object.prototype.hasOwnProperty.call(body, "activeRunStartedAt");
1948
- if (touchesFlag && this.opts.outbox) {
1951
+ const startsRun = touchesFlag && body.activeRunStartedAt !== null && body.activeRunStartedAt !== void 0;
1952
+ if (touchesFlag && !startsRun && this.opts.outbox) {
1949
1953
  return this.durableActiveRunWrite(path, conversationId, agentId, body);
1950
1954
  }
1955
+ if (startsRun) {
1956
+ const res = await this.request("PATCH", path, body, {
1957
+ retry: true
1958
+ });
1959
+ this.opts.outbox?.remove(activeRunOutboxKey(conversationId, agentId), ACTIVE_RUN_OUTBOX_SEQ);
1960
+ return res;
1961
+ }
1951
1962
  return this.request("PATCH", path, body);
1952
1963
  }
1953
1964
  // CT93: send-or-enqueue for an active-run flag write, with last-writer-wins
@@ -2318,7 +2329,7 @@ var CursorTracker = class {
2318
2329
  };
2319
2330
 
2320
2331
  // src/dispatch-dedupe.ts
2321
- import { mkdirSync as mkdirSync7, readFileSync as readFileSync5, writeFileSync as writeFileSync5, existsSync as existsSync7 } from "fs";
2332
+ import { mkdirSync as mkdirSync7, readFileSync as readFileSync5, writeFileSync as writeFileSync5, renameSync as renameSync3, existsSync as existsSync7 } from "fs";
2322
2333
  import { join as join9 } from "path";
2323
2334
  var MAX_IDS = 256;
2324
2335
  function dir(log) {
@@ -2358,6 +2369,7 @@ function hasCompleted(workspaceId, eventId) {
2358
2369
  }
2359
2370
  function markCompleted(workspaceId, eventId) {
2360
2371
  mark("completed", workspaceId, eventId);
2372
+ forgetTurnId(workspaceId, eventId);
2361
2373
  }
2362
2374
  var MAX_RESUME_ATTEMPTS = 3;
2363
2375
  function resumeDir() {
@@ -2399,6 +2411,56 @@ function bumpResumeAttempt(workspaceId, eventId) {
2399
2411
  );
2400
2412
  return next;
2401
2413
  }
2414
+ function turnDir() {
2415
+ return join9(cabaneDir(), "turns");
2416
+ }
2417
+ function turnPathFor(workspaceId) {
2418
+ return join9(turnDir(), encodeURIComponent(workspaceId));
2419
+ }
2420
+ var TURN_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
2421
+ function readTurnIds(workspaceId) {
2422
+ const out = /* @__PURE__ */ new Map();
2423
+ const path = turnPathFor(workspaceId);
2424
+ if (!existsSync7(path)) return out;
2425
+ try {
2426
+ for (const line of readFileSync5(path, "utf8").split("\n")) {
2427
+ const trimmed = line.trim();
2428
+ if (!trimmed) continue;
2429
+ const tab = trimmed.lastIndexOf(" ");
2430
+ if (tab <= 0) continue;
2431
+ const id = trimmed.slice(0, tab);
2432
+ const turnId = trimmed.slice(tab + 1);
2433
+ if (id && TURN_ID_RE.test(turnId)) out.set(id, turnId);
2434
+ }
2435
+ } catch {
2436
+ return out;
2437
+ }
2438
+ return out;
2439
+ }
2440
+ function turnIdForEvent(workspaceId, eventId) {
2441
+ return readTurnIds(workspaceId).get(eventId) ?? null;
2442
+ }
2443
+ var TURN_ID_OVERFLOW_WARN = 8192;
2444
+ function writeTurnIds(workspaceId, turns) {
2445
+ const entries = [...turns.entries()];
2446
+ mkdirSync7(turnDir(), { recursive: true });
2447
+ const path = turnPathFor(workspaceId);
2448
+ const tmp = `${path}.${process.pid}.tmp`;
2449
+ writeFileSync5(tmp, entries.map(([id, t]) => `${id} ${t}`).join("\n") + "\n", "utf8");
2450
+ renameSync3(tmp, path);
2451
+ return entries.length;
2452
+ }
2453
+ function rememberTurnId(workspaceId, eventId, turnId) {
2454
+ const turns = readTurnIds(workspaceId);
2455
+ if (turns.get(eventId) === turnId) return turns.size;
2456
+ turns.set(eventId, turnId);
2457
+ return writeTurnIds(workspaceId, turns);
2458
+ }
2459
+ function forgetTurnId(workspaceId, eventId) {
2460
+ const turns = readTurnIds(workspaceId);
2461
+ if (!turns.delete(eventId)) return;
2462
+ writeTurnIds(workspaceId, turns);
2463
+ }
2402
2464
  function noResume() {
2403
2465
  return process.env.CABANE_COMPANION_NO_RESUME === "1";
2404
2466
  }
@@ -7539,6 +7601,18 @@ function checkoutState(cwd) {
7539
7601
  function runKey(conversationId, agentId) {
7540
7602
  return `${conversationId}|${agentId}`;
7541
7603
  }
7604
+ var LEASE_REFUSALS = /* @__PURE__ */ new Set([
7605
+ "dispatch_not_admitted",
7606
+ "turn_already_ended",
7607
+ "turn_belongs_elsewhere"
7608
+ ]);
7609
+ function leaseRefusal(err) {
7610
+ if (!(err instanceof ApiError)) return null;
7611
+ const body = err.body;
7612
+ const code = typeof body === "object" && body !== null && typeof body.error === "string" ? body.error : null;
7613
+ if (code && LEASE_REFUSALS.has(code)) return code;
7614
+ return null;
7615
+ }
7542
7616
  var ERROR_BODY_LOG_CAP = 2e3;
7543
7617
  function describeErrorBody(body) {
7544
7618
  if (body === void 0 || body === null) return void 0;
@@ -7573,6 +7647,16 @@ var Dispatcher = class {
7573
7647
  }
7574
7648
  opts;
7575
7649
  // SJ383: per-(conversation, agent) abort registry.
7650
+ // CT1288: RunKey -> (turnId -> controller). This used to be one controller
7651
+ // per (conversation, agent), which quietly encoded the invariant the whole
7652
+ // task exists to enforce: that a pair can only ever have one live loop. When
7653
+ // that assumption broke, the second `set` EVICTED the first controller and
7654
+ // the first loop became permanently uncancellable — no other code path can
7655
+ // reach into a running turn. So the registry that Stop depends on failed
7656
+ // exactly when Stop was the thing you needed.
7657
+ //
7658
+ // Nesting by turn id costs nothing in the normal single-turn case and makes
7659
+ // `cancel` total: it aborts every loop under the pair, not the newest one.
7576
7660
  aborts;
7577
7661
  // CT1109: pairs already told, in the conversation, that their session state is
7578
7662
  // being refused. The failure repeats every single turn until someone fixes the
@@ -7635,7 +7719,12 @@ var Dispatcher = class {
7635
7719
  this.notifyEnd({ id: payload.messageId, ok: false, durationMs, reason });
7636
7720
  return { ok: false, durationMs, reason };
7637
7721
  }
7638
- async handle(payload) {
7722
+ // CT1288: `opts.turnId` is a RESUMED turn id — the id this event already ran
7723
+ // under before the companion died. Passing it makes the replay ask the
7724
+ // mailroom to re-admit the turn it already owns, which the server explicitly
7725
+ // supports ("a replay reports, it never re-points"). Omitted on a fresh
7726
+ // dispatch, where minting below is correct.
7727
+ async handle(payload, opts = {}) {
7639
7728
  const startedAt = Date.now();
7640
7729
  const dispatchId = payload.messageId;
7641
7730
  const workspaceId = this.opts.workspaceId;
@@ -7645,7 +7734,7 @@ var Dispatcher = class {
7645
7734
  agentId: payload.agentId,
7646
7735
  messageId: payload.messageId
7647
7736
  });
7648
- const turnId = randomUUID();
7737
+ const turnId = opts.turnId ?? randomUUID();
7649
7738
  let turnContext;
7650
7739
  try {
7651
7740
  turnContext = await this.opts.api.getTurnContext(
@@ -7867,7 +7956,18 @@ ${reason}`,
7867
7956
  };
7868
7957
  const key = runKey(payload.conversationId, payload.agentId);
7869
7958
  const abortController = new AbortController();
7870
- this.aborts.set(key, abortController);
7959
+ let pairAborts = this.aborts.get(key);
7960
+ if (!pairAborts) {
7961
+ pairAborts = /* @__PURE__ */ new Map();
7962
+ this.aborts.set(key, pairAborts);
7963
+ }
7964
+ pairAborts.set(turnId, abortController);
7965
+ const releaseAbort = () => {
7966
+ const pair = this.aborts.get(key);
7967
+ if (pair?.get(turnId) !== abortController) return;
7968
+ pair.delete(turnId);
7969
+ if (pair.size === 0) this.aborts.delete(key);
7970
+ };
7871
7971
  let timeoutReason = null;
7872
7972
  try {
7873
7973
  await this.opts.api.setActiveRun(workspaceId, payload.conversationId, payload.agentId, {
@@ -7880,10 +7980,21 @@ ${reason}`,
7880
7980
  turnId
7881
7981
  });
7882
7982
  } catch (err) {
7883
- turnLog.warn(
7884
- { err: err instanceof Error ? err.message : String(err) },
7885
- "dispatcher: active-run flag set failed terminally; proceeding"
7983
+ const refusal = leaseRefusal(err);
7984
+ releaseAbort();
7985
+ if (refusal === "dispatch_not_admitted" || refusal === "turn_already_ended") {
7986
+ turnLog.error(
7987
+ { refusal, turnId },
7988
+ "dispatcher: refused a turn lease; not running the model"
7989
+ );
7990
+ return this.concludeBeforeRun(payload, turnLog, startedAt, `lease_refused: ${refusal}`);
7991
+ }
7992
+ const reason = refusal ? `lease_refused: ${refusal}` : "lease_unconfirmed";
7993
+ turnLog.error(
7994
+ { refusal, turnId, err: err instanceof Error ? err.message : String(err) },
7995
+ "dispatcher: turn lease not confirmed; not running the model"
7886
7996
  );
7997
+ return this.concludeBeforeRun(payload, turnLog, startedAt, reason, reason);
7887
7998
  }
7888
7999
  const sendState = createSendState();
7889
8000
  const skipState = createSkipState();
@@ -7987,6 +8098,7 @@ ${reason}`,
7987
8098
  "dispatcher: runtime-unavailable notice post failed"
7988
8099
  );
7989
8100
  }
8101
+ releaseAbort();
7990
8102
  return this.concludeBeforeRun(
7991
8103
  payload,
7992
8104
  turnLog,
@@ -8386,9 +8498,7 @@ ${reason}`,
8386
8498
  "dispatcher: active-run clear failed terminally; server age-sweep is the backstop"
8387
8499
  );
8388
8500
  }
8389
- if (this.aborts.get(key) === abortController) {
8390
- this.aborts.delete(key);
8391
- }
8501
+ releaseAbort();
8392
8502
  }
8393
8503
  const durationMs = Date.now() - startedAt;
8394
8504
  const finalReplyBody = committer.replyBody;
@@ -8432,11 +8542,13 @@ ${reason}`,
8432
8542
  // THIS companion process. Returns true if an in-flight run was aborted.
8433
8543
  cancel(conversationId, agentId) {
8434
8544
  const key = runKey(conversationId, agentId);
8435
- const ac = this.aborts.get(key);
8436
- if (!ac) return false;
8437
- try {
8438
- ac.abort();
8439
- } catch {
8545
+ const pair = this.aborts.get(key);
8546
+ if (!pair || pair.size === 0) return false;
8547
+ for (const ac of [...pair.values()]) {
8548
+ try {
8549
+ ac.abort();
8550
+ } catch {
8551
+ }
8440
8552
  }
8441
8553
  return true;
8442
8554
  }
@@ -8500,7 +8612,7 @@ import {
8500
8612
  mkdirSync as mkdirSync10,
8501
8613
  readdirSync as readdirSync3,
8502
8614
  readFileSync as readFileSync8,
8503
- renameSync as renameSync3,
8615
+ renameSync as renameSync4,
8504
8616
  rmSync as rmSync7,
8505
8617
  writeFileSync as writeFileSync7
8506
8618
  } from "fs";
@@ -8532,7 +8644,7 @@ var Outbox = class {
8532
8644
  const tmp = `${target}.${process.pid}.tmp`;
8533
8645
  try {
8534
8646
  writeFileSync7(tmp, JSON.stringify(entry) + "\n", "utf8");
8535
- renameSync3(tmp, target);
8647
+ renameSync4(tmp, target);
8536
8648
  } catch (err) {
8537
8649
  try {
8538
8650
  rmSync7(tmp, { force: true });
@@ -9333,6 +9445,19 @@ var CompanionSupervisor = class {
9333
9445
  type: "device:dispatch_requested",
9334
9446
  ...wire.payload
9335
9447
  };
9448
+ if (this.deviceId && payload.deviceId && payload.deviceId !== this.deviceId) {
9449
+ this.log.debug(
9450
+ {
9451
+ workspaceId: wr.workspaceId,
9452
+ conversationId: payload.conversationId,
9453
+ agentId: payload.agentId,
9454
+ addressedTo: payload.deviceId
9455
+ },
9456
+ "companion: dispatch addressed to another device; ignoring"
9457
+ );
9458
+ if (ev.id) wr.cursor.settle(ev.id);
9459
+ return;
9460
+ }
9336
9461
  let agent = wr.agents.get(payload.agentId);
9337
9462
  if (!agent) {
9338
9463
  agent = await this.recoverRacedAgent(wr, payload);
@@ -9464,8 +9589,25 @@ var CompanionSupervisor = class {
9464
9589
  "companion: re-dispatching interrupted turn (resume after restart)"
9465
9590
  );
9466
9591
  }
9467
- if (ev.id) markDispatched(workspaceId, ev.id);
9468
- const result = await agent.dispatcher.handle(payload);
9592
+ const resumedTurnId = ev.id ? turnIdForEvent(workspaceId, ev.id) : null;
9593
+ const turnId = resumedTurnId ?? randomUUID2();
9594
+ if (ev.id) {
9595
+ const liveTurnIds = rememberTurnId(workspaceId, ev.id, turnId);
9596
+ if (liveTurnIds > TURN_ID_OVERFLOW_WARN) {
9597
+ this.log.error(
9598
+ { workspaceId, liveTurnIds, threshold: TURN_ID_OVERFLOW_WARN },
9599
+ "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."
9600
+ );
9601
+ }
9602
+ markDispatched(workspaceId, ev.id);
9603
+ }
9604
+ if (resumedTurnId) {
9605
+ this.log.info(
9606
+ { workspaceId, eventId: ev.id, turnId },
9607
+ "companion: resuming an interrupted turn under its original id"
9608
+ );
9609
+ }
9610
+ const result = await agent.dispatcher.handle(payload, { turnId });
9469
9611
  if (ev.id) markCompleted(workspaceId, ev.id);
9470
9612
  if (ev.id) wr.cursor.settle(ev.id);
9471
9613
  const durationS = (result.durationMs / 1e3).toFixed(1);
@@ -9856,7 +9998,7 @@ async function createCompanionRuntime(opts = {}) {
9856
9998
  opencodeServerUrl: cfg.opencode?.serverUrl,
9857
9999
  codex: isCodexEnabled(cfg)
9858
10000
  });
9859
- const instanceId = randomUUID2();
10001
+ const instanceId = randomUUID3();
9860
10002
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
9861
10003
  const pre = readLiveRuntimeState();
9862
10004
  if (pre && await verifyRuntime(pre) === "stale") clearRuntimeState();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cabane/companion",
3
- "version": "0.6.63",
3
+ "version": "0.6.64",
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",