@cabane/companion 0.6.88 → 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.
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
@@ -7967,6 +7979,39 @@ async function writeCodexInstructionsFile(contents) {
7967
7979
  };
7968
7980
  }
7969
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
+
7970
8015
  // src/prepared.ts
7971
8016
  import { mkdirSync as mkdirSync8, readFileSync as readFileSync6, rmSync as rmSync5, writeFileSync as writeFileSync6, existsSync as existsSync8 } from "fs";
7972
8017
  import { join as join11 } from "path";
@@ -8417,6 +8462,7 @@ var TurnExecution = class {
8417
8462
  this.opts = opts;
8418
8463
  this.supervisor = supervisor;
8419
8464
  this.payload = payload;
8465
+ this.resumed = handleOpts.resumed === true;
8420
8466
  this.dispatchId = payload.messageId;
8421
8467
  this.workspaceId = opts.workspaceId;
8422
8468
  this.turnLog = opts.log.child({
@@ -8439,7 +8485,13 @@ var TurnExecution = class {
8439
8485
  outcome = initialOutcome();
8440
8486
  seqCounter = 0;
8441
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.
8442
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;
8443
8495
  turnContext;
8444
8496
  resolvedMcpServers;
8445
8497
  effectiveCwd;
@@ -8485,6 +8537,10 @@ var TurnExecution = class {
8485
8537
  await this.acquireLease();
8486
8538
  await this.selectAdapter();
8487
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
+ }
8488
8544
  if (err instanceof TurnConcluded) {
8489
8545
  this.supervisor.releaseAbort(this.turnId, this.abortController);
8490
8546
  return this.admitted ? this.concludeAdmittedRun(err.reason, err.errorReason) : this.concludeBeforeRun(err.reason, err.errorReason);
@@ -8563,12 +8619,14 @@ var TurnExecution = class {
8563
8619
  }
8564
8620
  async fetchContext() {
8565
8621
  const { payload, turnId, turnLog } = this;
8622
+ const outboxFloor = this.resumed ? readOutboxFloor((id) => this.opts.api.outboxMaxSeqForTurn(id), turnId, turnLog) : 0;
8566
8623
  let turnContext;
8567
8624
  try {
8568
8625
  turnContext = await this.opts.api.getTurnContext(
8569
8626
  payload.conversationId,
8570
8627
  payload.messageId,
8571
- turnId
8628
+ turnId,
8629
+ this.resumed
8572
8630
  );
8573
8631
  } catch (err) {
8574
8632
  const status2 = err instanceof ApiError ? err.status : 0;
@@ -8585,6 +8643,11 @@ var TurnExecution = class {
8585
8643
  throw this.concluded(fetchReason, fetchReason);
8586
8644
  }
8587
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
+ }
8588
8651
  }
8589
8652
  gateTrigger() {
8590
8653
  const { payload, turnLog } = this;
@@ -8815,6 +8878,8 @@ ${reason}`,
8815
8878
  try {
8816
8879
  await this.opts.api.setActiveRun(workspaceId, payload.conversationId, payload.agentId, {
8817
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 } : {},
8818
8883
  // CT33: hand the server this turn's id so the new-run chokepoint's
8819
8884
  // `closeAbandonedTurns` sweep excludes it. The prepare hook may have
8820
8885
  // already emitted a "preparing" activity row for this turn above (it
@@ -9563,6 +9628,35 @@ var Outbox = class {
9563
9628
  );
9564
9629
  return entries;
9565
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
+ }
9566
9660
  // Remove a delivered (or terminally-discarded) entry. No-op if already gone.
9567
9661
  remove(turnId, seq) {
9568
9662
  try {
@@ -10479,7 +10573,10 @@ var CompanionSupervisor = class {
10479
10573
  "companion: resuming an interrupted turn under its original id"
10480
10574
  );
10481
10575
  }
10482
- const result = await agent.dispatcher.handle(payload, { turnId });
10576
+ const result = await agent.dispatcher.handle(payload, {
10577
+ turnId,
10578
+ resumed: resumedTurnId !== null
10579
+ });
10483
10580
  if (ev.id) markCompleted(workspaceId, ev.id);
10484
10581
  if (ev.id) wr.cursor.settle(ev.id);
10485
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
@@ -7464,6 +7476,39 @@ async function writeCodexInstructionsFile(contents) {
7464
7476
  };
7465
7477
  }
7466
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
+
7467
7512
  // src/prepared.ts
7468
7513
  import { mkdirSync as mkdirSync8, readFileSync as readFileSync6, rmSync as rmSync5, writeFileSync as writeFileSync6, existsSync as existsSync8 } from "fs";
7469
7514
  import { join as join11 } from "path";
@@ -7914,6 +7959,7 @@ var TurnExecution = class {
7914
7959
  this.opts = opts;
7915
7960
  this.supervisor = supervisor;
7916
7961
  this.payload = payload;
7962
+ this.resumed = handleOpts.resumed === true;
7917
7963
  this.dispatchId = payload.messageId;
7918
7964
  this.workspaceId = opts.workspaceId;
7919
7965
  this.turnLog = opts.log.child({
@@ -7936,7 +7982,13 @@ var TurnExecution = class {
7936
7982
  outcome = initialOutcome();
7937
7983
  seqCounter = 0;
7938
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.
7939
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;
7940
7992
  turnContext;
7941
7993
  resolvedMcpServers;
7942
7994
  effectiveCwd;
@@ -7982,6 +8034,10 @@ var TurnExecution = class {
7982
8034
  await this.acquireLease();
7983
8035
  await this.selectAdapter();
7984
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
+ }
7985
8041
  if (err instanceof TurnConcluded) {
7986
8042
  this.supervisor.releaseAbort(this.turnId, this.abortController);
7987
8043
  return this.admitted ? this.concludeAdmittedRun(err.reason, err.errorReason) : this.concludeBeforeRun(err.reason, err.errorReason);
@@ -8060,12 +8116,14 @@ var TurnExecution = class {
8060
8116
  }
8061
8117
  async fetchContext() {
8062
8118
  const { payload, turnId, turnLog } = this;
8119
+ const outboxFloor = this.resumed ? readOutboxFloor((id) => this.opts.api.outboxMaxSeqForTurn(id), turnId, turnLog) : 0;
8063
8120
  let turnContext;
8064
8121
  try {
8065
8122
  turnContext = await this.opts.api.getTurnContext(
8066
8123
  payload.conversationId,
8067
8124
  payload.messageId,
8068
- turnId
8125
+ turnId,
8126
+ this.resumed
8069
8127
  );
8070
8128
  } catch (err) {
8071
8129
  const status = err instanceof ApiError ? err.status : 0;
@@ -8082,6 +8140,11 @@ var TurnExecution = class {
8082
8140
  throw this.concluded(fetchReason, fetchReason);
8083
8141
  }
8084
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
+ }
8085
8148
  }
8086
8149
  gateTrigger() {
8087
8150
  const { payload, turnLog } = this;
@@ -8312,6 +8375,8 @@ ${reason}`,
8312
8375
  try {
8313
8376
  await this.opts.api.setActiveRun(workspaceId, payload.conversationId, payload.agentId, {
8314
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 } : {},
8315
8380
  // CT33: hand the server this turn's id so the new-run chokepoint's
8316
8381
  // `closeAbandonedTurns` sweep excludes it. The prepare hook may have
8317
8382
  // already emitted a "preparing" activity row for this turn above (it
@@ -9060,6 +9125,35 @@ var Outbox = class {
9060
9125
  );
9061
9126
  return entries;
9062
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
+ }
9063
9157
  // Remove a delivered (or terminally-discarded) entry. No-op if already gone.
9064
9158
  remove(turnId, seq) {
9065
9159
  try {
@@ -9976,7 +10070,10 @@ var CompanionSupervisor = class {
9976
10070
  "companion: resuming an interrupted turn under its original id"
9977
10071
  );
9978
10072
  }
9979
- const result = await agent.dispatcher.handle(payload, { turnId });
10073
+ const result = await agent.dispatcher.handle(payload, {
10074
+ turnId,
10075
+ resumed: resumedTurnId !== null
10076
+ });
9980
10077
  if (ev.id) markCompleted(workspaceId, ev.id);
9981
10078
  if (ev.id) wr.cursor.settle(ev.id);
9982
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.88",
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",