@cabane/companion 0.6.87 → 0.6.89

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 +158 -13
  2. package/dist/runtime.js +158 -13
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -2457,10 +2457,22 @@ var CabaneApi = class {
2457
2457
  // token whose turn has ended. The dispatcher mints it before this call and
2458
2458
  // reuses the same value on its active-run PATCH, so the token's turn id and the
2459
2459
  // pair's `active_turn_id` agree.
2460
- getTurnContext(conversationId, messageId2, turnId) {
2461
- const q = `conversationId=${encodeURIComponent(conversationId)}&messageId=${encodeURIComponent(messageId2)}` + (turnId ? `&turnId=${encodeURIComponent(turnId)}` : "");
2460
+ // CT1380: `resumed` says this dispatch is a REPLAY of an interrupted turn, so
2461
+ // the server should hand back `turnSeqFloor` the turn's committed seq
2462
+ // high-water mark. Only the supervisor knows (it minted the id or recovered
2463
+ // it), so it is passed down rather than inferred here. It gates the COST of
2464
+ // the aggregate, never the tenancy scoping, which the route applies
2465
+ // unconditionally.
2466
+ getTurnContext(conversationId, messageId2, turnId, resumed) {
2467
+ const q = `conversationId=${encodeURIComponent(conversationId)}&messageId=${encodeURIComponent(messageId2)}` + (turnId ? `&turnId=${encodeURIComponent(turnId)}` : "") + (resumed ? "&resumed=1" : "");
2462
2468
  return this.request("GET", `/api/agent/turn-context?${q}`);
2463
2469
  }
2470
+ // CT1380: the local half of a resumed turn's seq floor (see
2471
+ // `Outbox.maxSeqForTurn`). 0 when no outbox is configured — a one-shot CLI or
2472
+ // test has no durable queue, so nothing can be hiding in it.
2473
+ outboxMaxSeqForTurn(turnId) {
2474
+ return this.opts.outbox?.maxSeqForTurn(turnId) ?? 0;
2475
+ }
2464
2476
  // CT714: read a turn's recorded turn-control intent. An EXTERNAL adapter
2465
2477
  // (Codex / opencode) records `reply_to` / `skip_turn` into `turn_intents`
2466
2478
  // server-side (the URL MCP surface) rather than the dispatcher's in-memory
@@ -2647,6 +2659,35 @@ var CabaneApi = class {
2647
2659
  signal
2648
2660
  );
2649
2661
  }
2662
+ // CT1357: post ONE ordinary addressed message — not turn speech, no `turnId`
2663
+ // — so `dispatch` is honoured and the target agent is woken. The turn-commit
2664
+ // fork ignores `dispatch` (an act rides its own call now), which leaves this
2665
+ // as the only way a companion can address a peer OUTSIDE a running turn.
2666
+ //
2667
+ // The prepare-failure handback is exactly that case and the reason this
2668
+ // exists. It fires before the turn is ever admitted — the active-run PATCH
2669
+ // that admits it comes after the prepare hook — so `turnAct` is not available:
2670
+ // its route resolves the turn first and 404s when there is none. The failure
2671
+ // notice's own commit is what admits the turn, and by then it has also settled
2672
+ // it. An ordinary addressed post depends on no lease at all, which is what
2673
+ // makes it the right shape here.
2674
+ //
2675
+ // Retried like any durable write, and therefore REQUIRING `idempotencyKey`:
2676
+ // this is not outbox-queued (there is no `(turnId, seq)` for a non-turn row),
2677
+ // so without a key a committed request whose response was lost — or a
2678
+ // redelivered dispatch event — writes a second row and wakes the peer twice.
2679
+ // The server dedupes on `(conversation_id, idempotency_key)`: the repeat
2680
+ // returns the existing row and raises no second dispatch (CT239). Required,
2681
+ // not optional, so a future caller cannot omit it and silently lose the
2682
+ // guarantee.
2683
+ postAddressedMessage(workspaceId, conversationId, body, signal) {
2684
+ return this.request(
2685
+ "POST",
2686
+ `/api/workspaces/${workspaceId}/conversations/${conversationId}/messages`,
2687
+ body,
2688
+ signal ? { retry: true, signal } : { retry: true }
2689
+ );
2690
+ }
2650
2691
  // SJ477: report one tool-activity transition (start / done / error) for the
2651
2692
  // live activity cards. Transient — the server publishes an `agent_activity`
2652
2693
  // SSE and writes no row. Agent-PAT authed; the URL `:agentId` must match the
@@ -7938,6 +7979,39 @@ async function writeCodexInstructionsFile(contents) {
7938
7979
  };
7939
7980
  }
7940
7981
 
7982
+ // src/turn-seq-floor.ts
7983
+ var SeqFloorUnavailable = class extends Error {
7984
+ constructor(detail) {
7985
+ super(`seq_floor_unavailable: ${detail}`);
7986
+ this.detail = detail;
7987
+ this.name = "SeqFloorUnavailable";
7988
+ }
7989
+ detail;
7990
+ };
7991
+ function resolveSeqFloor(sources, ctx) {
7992
+ const { serverFloor, outboxFloor } = sources;
7993
+ if (serverFloor === void 0) {
7994
+ throw new SeqFloorUnavailable("server sent no committed floor for a resumed turn");
7995
+ }
7996
+ const floor = Math.max(serverFloor, outboxFloor);
7997
+ ctx.log.info(
7998
+ { turnId: ctx.turnId, floor, outboxFloor, serverFloor },
7999
+ "companion: resumed turn \u2014 seq counter seeded above its committed high-water mark"
8000
+ );
8001
+ return floor;
8002
+ }
8003
+ function readOutboxFloor(read, turnId, log) {
8004
+ try {
8005
+ return read(turnId);
8006
+ } catch (err) {
8007
+ log.error(
8008
+ { turnId, err: err instanceof Error ? err.message : String(err) },
8009
+ "companion: the on-disk outbox floor is unreadable; refusing to resume"
8010
+ );
8011
+ throw new SeqFloorUnavailable("outbox unreadable");
8012
+ }
8013
+ }
8014
+
7941
8015
  // src/prepared.ts
7942
8016
  import { mkdirSync as mkdirSync8, readFileSync as readFileSync6, rmSync as rmSync5, writeFileSync as writeFileSync6, existsSync as existsSync8 } from "fs";
7943
8017
  import { join as join11 } from "path";
@@ -8388,6 +8462,7 @@ var TurnExecution = class {
8388
8462
  this.opts = opts;
8389
8463
  this.supervisor = supervisor;
8390
8464
  this.payload = payload;
8465
+ this.resumed = handleOpts.resumed === true;
8391
8466
  this.dispatchId = payload.messageId;
8392
8467
  this.workspaceId = opts.workspaceId;
8393
8468
  this.turnLog = opts.log.child({
@@ -8410,7 +8485,13 @@ var TurnExecution = class {
8410
8485
  outcome = initialOutcome();
8411
8486
  seqCounter = 0;
8412
8487
  // CT11: per-turn monotonic counter mirroring the in-process dispatcher's.
8488
+ //
8489
+ // CT1380: 0 for a NEW turn only — a resume is seeded in `fetchContext` first.
8413
8490
  nextSeq = () => ++this.seqCounter;
8491
+ // CT1380: a REPLAY, told to us by the supervisor — not derivable here, since
8492
+ // `turnId` is set on every dispatch. `resumedFromSeq` is what it seeded from.
8493
+ resumed;
8494
+ resumedFromSeq;
8414
8495
  turnContext;
8415
8496
  resolvedMcpServers;
8416
8497
  effectiveCwd;
@@ -8456,6 +8537,10 @@ var TurnExecution = class {
8456
8537
  await this.acquireLease();
8457
8538
  await this.selectAdapter();
8458
8539
  } catch (err) {
8540
+ if (err instanceof SeqFloorUnavailable) {
8541
+ this.supervisor.releaseAbort(this.turnId, this.abortController);
8542
+ return this.concludeBeforeRun(err.message, err.message);
8543
+ }
8459
8544
  if (err instanceof TurnConcluded) {
8460
8545
  this.supervisor.releaseAbort(this.turnId, this.abortController);
8461
8546
  return this.admitted ? this.concludeAdmittedRun(err.reason, err.errorReason) : this.concludeBeforeRun(err.reason, err.errorReason);
@@ -8534,12 +8619,14 @@ var TurnExecution = class {
8534
8619
  }
8535
8620
  async fetchContext() {
8536
8621
  const { payload, turnId, turnLog } = this;
8622
+ const outboxFloor = this.resumed ? readOutboxFloor((id) => this.opts.api.outboxMaxSeqForTurn(id), turnId, turnLog) : 0;
8537
8623
  let turnContext;
8538
8624
  try {
8539
8625
  turnContext = await this.opts.api.getTurnContext(
8540
8626
  payload.conversationId,
8541
8627
  payload.messageId,
8542
- turnId
8628
+ turnId,
8629
+ this.resumed
8543
8630
  );
8544
8631
  } catch (err) {
8545
8632
  const status2 = err instanceof ApiError ? err.status : 0;
@@ -8556,6 +8643,11 @@ var TurnExecution = class {
8556
8643
  throw this.concluded(fetchReason, fetchReason);
8557
8644
  }
8558
8645
  this.turnContext = turnContext;
8646
+ if (this.resumed) {
8647
+ const sources = { serverFloor: turnContext.turnSeqFloor, outboxFloor };
8648
+ this.resumedFromSeq = resolveSeqFloor(sources, { turnId, log: turnLog });
8649
+ this.seqCounter = Math.max(this.seqCounter, this.resumedFromSeq);
8650
+ }
8559
8651
  }
8560
8652
  gateTrigger() {
8561
8653
  const { payload, turnLog } = this;
@@ -8617,12 +8709,31 @@ var TurnExecution = class {
8617
8709
  }
8618
8710
  let hookEnv;
8619
8711
  const triggerIsPrepareFailure = this.turnContext.message.body.startsWith(PREPARE_FAILED_PREFIX);
8620
- const prepareFailureDispatch = this.turnContext.dispatchedByAgentId && !triggerIsPrepareFailure ? {
8621
- dispatch: this.turnContext.dispatchedByAgentId,
8622
- dispatchBody: `${PREPARE_FAILED_PREFIX}
8712
+ const sendPrepareFailureHandback = async () => {
8713
+ if (!this.turnContext.dispatchedByAgentId || triggerIsPrepareFailure) return;
8714
+ try {
8715
+ await this.opts.api.postAddressedMessage(workspaceId, payload.conversationId, {
8716
+ body: `${PREPARE_FAILED_PREFIX}
8623
8717
 
8624
- The dispatched turn could not start. Re-dispatch it after repairing the preparation failure shown in this conversation.`
8625
- } : {};
8718
+ The dispatched turn could not start. Re-dispatch it after repairing the preparation failure shown in this conversation.`,
8719
+ dispatch: this.turnContext.dispatchedByAgentId,
8720
+ // Keyed on the TRIGGER, not the turn: a redelivered dispatch event is
8721
+ // the replay this has to survive, and it mints a fresh `turnId` while
8722
+ // carrying the same `messageId`. One handback per failed pickup of a
8723
+ // given trigger — which is also CT992's rule, that the first failure
8724
+ // is the one carrying information. A genuine re-dispatch after a
8725
+ // repair authors a NEW trigger message, so it keys differently and
8726
+ // hands back again.
8727
+ idempotencyKey: `prepare-failure-handback:${payload.messageId}`,
8728
+ parentMessageId: payload.messageId
8729
+ });
8730
+ } catch (err) {
8731
+ turnLog.warn(
8732
+ { err: err instanceof Error ? err.message : String(err) },
8733
+ "dispatcher: prepare-failure handback failed"
8734
+ );
8735
+ }
8736
+ };
8626
8737
  if (prepareHook) {
8627
8738
  let cached2 = readPrepared(workspaceId, payload.conversationId, payload.agentId);
8628
8739
  if (cached2 && !checkoutState(cached2.cwd).ok) {
@@ -8661,8 +8772,7 @@ The dispatched turn could not start. Re-dispatch it after repairing the preparat
8661
8772
  ${reason}`,
8662
8773
  kind: "final",
8663
8774
  turnId,
8664
- parentMessageId: payload.messageId,
8665
- ...prepareFailureDispatch
8775
+ parentMessageId: payload.messageId
8666
8776
  });
8667
8777
  } catch (postErr) {
8668
8778
  turnLog.warn(
@@ -8670,6 +8780,7 @@ ${reason}`,
8670
8780
  "dispatcher: prepare-rejection post failed"
8671
8781
  );
8672
8782
  }
8783
+ await sendPrepareFailureHandback();
8673
8784
  throw this.concluded(`prepare_failed: ${reason}`);
8674
8785
  }
8675
8786
  }
@@ -8737,8 +8848,7 @@ ${reason}`,
8737
8848
  kind: "final",
8738
8849
  turnId,
8739
8850
  // CT113: stamp the parent even on the prepare-failed close.
8740
- parentMessageId: payload.messageId,
8741
- ...prepareFailureDispatch
8851
+ parentMessageId: payload.messageId
8742
8852
  });
8743
8853
  } catch (postErr) {
8744
8854
  turnLog.warn(
@@ -8746,6 +8856,7 @@ ${reason}`,
8746
8856
  "dispatcher: prepare-failure post failed"
8747
8857
  );
8748
8858
  }
8859
+ await sendPrepareFailureHandback();
8749
8860
  const failReason = `prepare_failed: ${reason}`;
8750
8861
  throw this.concluded(failReason);
8751
8862
  }
@@ -8767,6 +8878,8 @@ ${reason}`,
8767
8878
  try {
8768
8879
  await this.opts.api.setActiveRun(workspaceId, payload.conversationId, payload.agentId, {
8769
8880
  activeRunStartedAt: (/* @__PURE__ */ new Date()).toISOString(),
8881
+ // CT1380: the durable record that this turn resumed, and from where.
8882
+ ...this.resumedFromSeq !== void 0 ? { resumedFromSeq: this.resumedFromSeq } : {},
8770
8883
  // CT33: hand the server this turn's id so the new-run chokepoint's
8771
8884
  // `closeAbandonedTurns` sweep excludes it. The prepare hook may have
8772
8885
  // already emitted a "preparing" activity row for this turn above (it
@@ -9515,6 +9628,35 @@ var Outbox = class {
9515
9628
  );
9516
9629
  return entries;
9517
9630
  }
9631
+ // CT1380: the highest `seq` this turn has queued but not yet delivered — the
9632
+ // half of a resumed turn's seq floor the SERVER CANNOT SEE. A commit that
9633
+ // failed transiently before the crash is sitting in this directory with its
9634
+ // seq already spent, and the restart's drain is fire-and-forget
9635
+ // (`drain.kick()`, never awaited), so `maxSeqForTurn` on the server can report
9636
+ // below it and the resumed run would mint that number a second time.
9637
+ //
9638
+ // Reads the directory rather than `list()` because only the filename matters:
9639
+ // `<turnId>__<seq>.json` carries both halves of the key, so a corrupt body
9640
+ // can't hide a spent seq from the floor. Returns 0 for a turn with nothing
9641
+ // queued, which is the overwhelmingly common case.
9642
+ // THROWS rather than returning 0 when the directory exists but cannot be
9643
+ // read. 0 is a claim ("this turn has nothing queued"), and an unreadable
9644
+ // directory cannot support it — a queued entry holding seq N would be
9645
+ // invisible and the resumed run would mint N again. A missing directory is
9646
+ // different: it is positive evidence that nothing was ever queued here.
9647
+ maxSeqForTurn(turnId) {
9648
+ const dir2 = this.dir();
9649
+ if (!existsSync12(dir2)) return 0;
9650
+ const names = readdirSync3(dir2);
9651
+ const prefix = `${encodeURIComponent(turnId)}__`;
9652
+ let max = 0;
9653
+ for (const name of names) {
9654
+ if (!name.startsWith(prefix) || !name.endsWith(".json")) continue;
9655
+ const seq = Number(name.slice(prefix.length, -".json".length));
9656
+ if (Number.isInteger(seq) && seq > max) max = seq;
9657
+ }
9658
+ return max;
9659
+ }
9518
9660
  // Remove a delivered (or terminally-discarded) entry. No-op if already gone.
9519
9661
  remove(turnId, seq) {
9520
9662
  try {
@@ -10431,7 +10573,10 @@ var CompanionSupervisor = class {
10431
10573
  "companion: resuming an interrupted turn under its original id"
10432
10574
  );
10433
10575
  }
10434
- const result = await agent.dispatcher.handle(payload, { turnId });
10576
+ const result = await agent.dispatcher.handle(payload, {
10577
+ turnId,
10578
+ resumed: resumedTurnId !== null
10579
+ });
10435
10580
  if (ev.id) markCompleted(workspaceId, ev.id);
10436
10581
  if (ev.id) wr.cursor.settle(ev.id);
10437
10582
  const durationS = (result.durationMs / 1e3).toFixed(1);
package/dist/runtime.js CHANGED
@@ -1875,10 +1875,22 @@ var CabaneApi = class {
1875
1875
  // token whose turn has ended. The dispatcher mints it before this call and
1876
1876
  // reuses the same value on its active-run PATCH, so the token's turn id and the
1877
1877
  // pair's `active_turn_id` agree.
1878
- getTurnContext(conversationId, messageId2, turnId) {
1879
- const q = `conversationId=${encodeURIComponent(conversationId)}&messageId=${encodeURIComponent(messageId2)}` + (turnId ? `&turnId=${encodeURIComponent(turnId)}` : "");
1878
+ // CT1380: `resumed` says this dispatch is a REPLAY of an interrupted turn, so
1879
+ // the server should hand back `turnSeqFloor` the turn's committed seq
1880
+ // high-water mark. Only the supervisor knows (it minted the id or recovered
1881
+ // it), so it is passed down rather than inferred here. It gates the COST of
1882
+ // the aggregate, never the tenancy scoping, which the route applies
1883
+ // unconditionally.
1884
+ getTurnContext(conversationId, messageId2, turnId, resumed) {
1885
+ const q = `conversationId=${encodeURIComponent(conversationId)}&messageId=${encodeURIComponent(messageId2)}` + (turnId ? `&turnId=${encodeURIComponent(turnId)}` : "") + (resumed ? "&resumed=1" : "");
1880
1886
  return this.request("GET", `/api/agent/turn-context?${q}`);
1881
1887
  }
1888
+ // CT1380: the local half of a resumed turn's seq floor (see
1889
+ // `Outbox.maxSeqForTurn`). 0 when no outbox is configured — a one-shot CLI or
1890
+ // test has no durable queue, so nothing can be hiding in it.
1891
+ outboxMaxSeqForTurn(turnId) {
1892
+ return this.opts.outbox?.maxSeqForTurn(turnId) ?? 0;
1893
+ }
1882
1894
  // CT714: read a turn's recorded turn-control intent. An EXTERNAL adapter
1883
1895
  // (Codex / opencode) records `reply_to` / `skip_turn` into `turn_intents`
1884
1896
  // server-side (the URL MCP surface) rather than the dispatcher's in-memory
@@ -2065,6 +2077,35 @@ var CabaneApi = class {
2065
2077
  signal
2066
2078
  );
2067
2079
  }
2080
+ // CT1357: post ONE ordinary addressed message — not turn speech, no `turnId`
2081
+ // — so `dispatch` is honoured and the target agent is woken. The turn-commit
2082
+ // fork ignores `dispatch` (an act rides its own call now), which leaves this
2083
+ // as the only way a companion can address a peer OUTSIDE a running turn.
2084
+ //
2085
+ // The prepare-failure handback is exactly that case and the reason this
2086
+ // exists. It fires before the turn is ever admitted — the active-run PATCH
2087
+ // that admits it comes after the prepare hook — so `turnAct` is not available:
2088
+ // its route resolves the turn first and 404s when there is none. The failure
2089
+ // notice's own commit is what admits the turn, and by then it has also settled
2090
+ // it. An ordinary addressed post depends on no lease at all, which is what
2091
+ // makes it the right shape here.
2092
+ //
2093
+ // Retried like any durable write, and therefore REQUIRING `idempotencyKey`:
2094
+ // this is not outbox-queued (there is no `(turnId, seq)` for a non-turn row),
2095
+ // so without a key a committed request whose response was lost — or a
2096
+ // redelivered dispatch event — writes a second row and wakes the peer twice.
2097
+ // The server dedupes on `(conversation_id, idempotency_key)`: the repeat
2098
+ // returns the existing row and raises no second dispatch (CT239). Required,
2099
+ // not optional, so a future caller cannot omit it and silently lose the
2100
+ // guarantee.
2101
+ postAddressedMessage(workspaceId, conversationId, body, signal) {
2102
+ return this.request(
2103
+ "POST",
2104
+ `/api/workspaces/${workspaceId}/conversations/${conversationId}/messages`,
2105
+ body,
2106
+ signal ? { retry: true, signal } : { retry: true }
2107
+ );
2108
+ }
2068
2109
  // SJ477: report one tool-activity transition (start / done / error) for the
2069
2110
  // live activity cards. Transient — the server publishes an `agent_activity`
2070
2111
  // SSE and writes no row. Agent-PAT authed; the URL `:agentId` must match the
@@ -7435,6 +7476,39 @@ async function writeCodexInstructionsFile(contents) {
7435
7476
  };
7436
7477
  }
7437
7478
 
7479
+ // src/turn-seq-floor.ts
7480
+ var SeqFloorUnavailable = class extends Error {
7481
+ constructor(detail) {
7482
+ super(`seq_floor_unavailable: ${detail}`);
7483
+ this.detail = detail;
7484
+ this.name = "SeqFloorUnavailable";
7485
+ }
7486
+ detail;
7487
+ };
7488
+ function resolveSeqFloor(sources, ctx) {
7489
+ const { serverFloor, outboxFloor } = sources;
7490
+ if (serverFloor === void 0) {
7491
+ throw new SeqFloorUnavailable("server sent no committed floor for a resumed turn");
7492
+ }
7493
+ const floor = Math.max(serverFloor, outboxFloor);
7494
+ ctx.log.info(
7495
+ { turnId: ctx.turnId, floor, outboxFloor, serverFloor },
7496
+ "companion: resumed turn \u2014 seq counter seeded above its committed high-water mark"
7497
+ );
7498
+ return floor;
7499
+ }
7500
+ function readOutboxFloor(read, turnId, log) {
7501
+ try {
7502
+ return read(turnId);
7503
+ } catch (err) {
7504
+ log.error(
7505
+ { turnId, err: err instanceof Error ? err.message : String(err) },
7506
+ "companion: the on-disk outbox floor is unreadable; refusing to resume"
7507
+ );
7508
+ throw new SeqFloorUnavailable("outbox unreadable");
7509
+ }
7510
+ }
7511
+
7438
7512
  // src/prepared.ts
7439
7513
  import { mkdirSync as mkdirSync8, readFileSync as readFileSync6, rmSync as rmSync5, writeFileSync as writeFileSync6, existsSync as existsSync8 } from "fs";
7440
7514
  import { join as join11 } from "path";
@@ -7885,6 +7959,7 @@ var TurnExecution = class {
7885
7959
  this.opts = opts;
7886
7960
  this.supervisor = supervisor;
7887
7961
  this.payload = payload;
7962
+ this.resumed = handleOpts.resumed === true;
7888
7963
  this.dispatchId = payload.messageId;
7889
7964
  this.workspaceId = opts.workspaceId;
7890
7965
  this.turnLog = opts.log.child({
@@ -7907,7 +7982,13 @@ var TurnExecution = class {
7907
7982
  outcome = initialOutcome();
7908
7983
  seqCounter = 0;
7909
7984
  // CT11: per-turn monotonic counter mirroring the in-process dispatcher's.
7985
+ //
7986
+ // CT1380: 0 for a NEW turn only — a resume is seeded in `fetchContext` first.
7910
7987
  nextSeq = () => ++this.seqCounter;
7988
+ // CT1380: a REPLAY, told to us by the supervisor — not derivable here, since
7989
+ // `turnId` is set on every dispatch. `resumedFromSeq` is what it seeded from.
7990
+ resumed;
7991
+ resumedFromSeq;
7911
7992
  turnContext;
7912
7993
  resolvedMcpServers;
7913
7994
  effectiveCwd;
@@ -7953,6 +8034,10 @@ var TurnExecution = class {
7953
8034
  await this.acquireLease();
7954
8035
  await this.selectAdapter();
7955
8036
  } catch (err) {
8037
+ if (err instanceof SeqFloorUnavailable) {
8038
+ this.supervisor.releaseAbort(this.turnId, this.abortController);
8039
+ return this.concludeBeforeRun(err.message, err.message);
8040
+ }
7956
8041
  if (err instanceof TurnConcluded) {
7957
8042
  this.supervisor.releaseAbort(this.turnId, this.abortController);
7958
8043
  return this.admitted ? this.concludeAdmittedRun(err.reason, err.errorReason) : this.concludeBeforeRun(err.reason, err.errorReason);
@@ -8031,12 +8116,14 @@ var TurnExecution = class {
8031
8116
  }
8032
8117
  async fetchContext() {
8033
8118
  const { payload, turnId, turnLog } = this;
8119
+ const outboxFloor = this.resumed ? readOutboxFloor((id) => this.opts.api.outboxMaxSeqForTurn(id), turnId, turnLog) : 0;
8034
8120
  let turnContext;
8035
8121
  try {
8036
8122
  turnContext = await this.opts.api.getTurnContext(
8037
8123
  payload.conversationId,
8038
8124
  payload.messageId,
8039
- turnId
8125
+ turnId,
8126
+ this.resumed
8040
8127
  );
8041
8128
  } catch (err) {
8042
8129
  const status = err instanceof ApiError ? err.status : 0;
@@ -8053,6 +8140,11 @@ var TurnExecution = class {
8053
8140
  throw this.concluded(fetchReason, fetchReason);
8054
8141
  }
8055
8142
  this.turnContext = turnContext;
8143
+ if (this.resumed) {
8144
+ const sources = { serverFloor: turnContext.turnSeqFloor, outboxFloor };
8145
+ this.resumedFromSeq = resolveSeqFloor(sources, { turnId, log: turnLog });
8146
+ this.seqCounter = Math.max(this.seqCounter, this.resumedFromSeq);
8147
+ }
8056
8148
  }
8057
8149
  gateTrigger() {
8058
8150
  const { payload, turnLog } = this;
@@ -8114,12 +8206,31 @@ var TurnExecution = class {
8114
8206
  }
8115
8207
  let hookEnv;
8116
8208
  const triggerIsPrepareFailure = this.turnContext.message.body.startsWith(PREPARE_FAILED_PREFIX);
8117
- const prepareFailureDispatch = this.turnContext.dispatchedByAgentId && !triggerIsPrepareFailure ? {
8118
- dispatch: this.turnContext.dispatchedByAgentId,
8119
- dispatchBody: `${PREPARE_FAILED_PREFIX}
8209
+ const sendPrepareFailureHandback = async () => {
8210
+ if (!this.turnContext.dispatchedByAgentId || triggerIsPrepareFailure) return;
8211
+ try {
8212
+ await this.opts.api.postAddressedMessage(workspaceId, payload.conversationId, {
8213
+ body: `${PREPARE_FAILED_PREFIX}
8120
8214
 
8121
- The dispatched turn could not start. Re-dispatch it after repairing the preparation failure shown in this conversation.`
8122
- } : {};
8215
+ The dispatched turn could not start. Re-dispatch it after repairing the preparation failure shown in this conversation.`,
8216
+ dispatch: this.turnContext.dispatchedByAgentId,
8217
+ // Keyed on the TRIGGER, not the turn: a redelivered dispatch event is
8218
+ // the replay this has to survive, and it mints a fresh `turnId` while
8219
+ // carrying the same `messageId`. One handback per failed pickup of a
8220
+ // given trigger — which is also CT992's rule, that the first failure
8221
+ // is the one carrying information. A genuine re-dispatch after a
8222
+ // repair authors a NEW trigger message, so it keys differently and
8223
+ // hands back again.
8224
+ idempotencyKey: `prepare-failure-handback:${payload.messageId}`,
8225
+ parentMessageId: payload.messageId
8226
+ });
8227
+ } catch (err) {
8228
+ turnLog.warn(
8229
+ { err: err instanceof Error ? err.message : String(err) },
8230
+ "dispatcher: prepare-failure handback failed"
8231
+ );
8232
+ }
8233
+ };
8123
8234
  if (prepareHook) {
8124
8235
  let cached2 = readPrepared(workspaceId, payload.conversationId, payload.agentId);
8125
8236
  if (cached2 && !checkoutState(cached2.cwd).ok) {
@@ -8158,8 +8269,7 @@ The dispatched turn could not start. Re-dispatch it after repairing the preparat
8158
8269
  ${reason}`,
8159
8270
  kind: "final",
8160
8271
  turnId,
8161
- parentMessageId: payload.messageId,
8162
- ...prepareFailureDispatch
8272
+ parentMessageId: payload.messageId
8163
8273
  });
8164
8274
  } catch (postErr) {
8165
8275
  turnLog.warn(
@@ -8167,6 +8277,7 @@ ${reason}`,
8167
8277
  "dispatcher: prepare-rejection post failed"
8168
8278
  );
8169
8279
  }
8280
+ await sendPrepareFailureHandback();
8170
8281
  throw this.concluded(`prepare_failed: ${reason}`);
8171
8282
  }
8172
8283
  }
@@ -8234,8 +8345,7 @@ ${reason}`,
8234
8345
  kind: "final",
8235
8346
  turnId,
8236
8347
  // CT113: stamp the parent even on the prepare-failed close.
8237
- parentMessageId: payload.messageId,
8238
- ...prepareFailureDispatch
8348
+ parentMessageId: payload.messageId
8239
8349
  });
8240
8350
  } catch (postErr) {
8241
8351
  turnLog.warn(
@@ -8243,6 +8353,7 @@ ${reason}`,
8243
8353
  "dispatcher: prepare-failure post failed"
8244
8354
  );
8245
8355
  }
8356
+ await sendPrepareFailureHandback();
8246
8357
  const failReason = `prepare_failed: ${reason}`;
8247
8358
  throw this.concluded(failReason);
8248
8359
  }
@@ -8264,6 +8375,8 @@ ${reason}`,
8264
8375
  try {
8265
8376
  await this.opts.api.setActiveRun(workspaceId, payload.conversationId, payload.agentId, {
8266
8377
  activeRunStartedAt: (/* @__PURE__ */ new Date()).toISOString(),
8378
+ // CT1380: the durable record that this turn resumed, and from where.
8379
+ ...this.resumedFromSeq !== void 0 ? { resumedFromSeq: this.resumedFromSeq } : {},
8267
8380
  // CT33: hand the server this turn's id so the new-run chokepoint's
8268
8381
  // `closeAbandonedTurns` sweep excludes it. The prepare hook may have
8269
8382
  // already emitted a "preparing" activity row for this turn above (it
@@ -9012,6 +9125,35 @@ var Outbox = class {
9012
9125
  );
9013
9126
  return entries;
9014
9127
  }
9128
+ // CT1380: the highest `seq` this turn has queued but not yet delivered — the
9129
+ // half of a resumed turn's seq floor the SERVER CANNOT SEE. A commit that
9130
+ // failed transiently before the crash is sitting in this directory with its
9131
+ // seq already spent, and the restart's drain is fire-and-forget
9132
+ // (`drain.kick()`, never awaited), so `maxSeqForTurn` on the server can report
9133
+ // below it and the resumed run would mint that number a second time.
9134
+ //
9135
+ // Reads the directory rather than `list()` because only the filename matters:
9136
+ // `<turnId>__<seq>.json` carries both halves of the key, so a corrupt body
9137
+ // can't hide a spent seq from the floor. Returns 0 for a turn with nothing
9138
+ // queued, which is the overwhelmingly common case.
9139
+ // THROWS rather than returning 0 when the directory exists but cannot be
9140
+ // read. 0 is a claim ("this turn has nothing queued"), and an unreadable
9141
+ // directory cannot support it — a queued entry holding seq N would be
9142
+ // invisible and the resumed run would mint N again. A missing directory is
9143
+ // different: it is positive evidence that nothing was ever queued here.
9144
+ maxSeqForTurn(turnId) {
9145
+ const dir2 = this.dir();
9146
+ if (!existsSync12(dir2)) return 0;
9147
+ const names = readdirSync3(dir2);
9148
+ const prefix = `${encodeURIComponent(turnId)}__`;
9149
+ let max = 0;
9150
+ for (const name of names) {
9151
+ if (!name.startsWith(prefix) || !name.endsWith(".json")) continue;
9152
+ const seq = Number(name.slice(prefix.length, -".json".length));
9153
+ if (Number.isInteger(seq) && seq > max) max = seq;
9154
+ }
9155
+ return max;
9156
+ }
9015
9157
  // Remove a delivered (or terminally-discarded) entry. No-op if already gone.
9016
9158
  remove(turnId, seq) {
9017
9159
  try {
@@ -9928,7 +10070,10 @@ var CompanionSupervisor = class {
9928
10070
  "companion: resuming an interrupted turn under its original id"
9929
10071
  );
9930
10072
  }
9931
- const result = await agent.dispatcher.handle(payload, { turnId });
10073
+ const result = await agent.dispatcher.handle(payload, {
10074
+ turnId,
10075
+ resumed: resumedTurnId !== null
10076
+ });
9932
10077
  if (ev.id) markCompleted(workspaceId, ev.id);
9933
10078
  if (ev.id) wr.cursor.settle(ev.id);
9934
10079
  const durationS = (result.durationMs / 1e3).toFixed(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cabane/companion",
3
- "version": "0.6.87",
3
+ "version": "0.6.89",
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",