@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.
- package/dist/cli.js +158 -13
- package/dist/runtime.js +158 -13
- 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
|
-
|
|
2461
|
-
|
|
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
|
|
8621
|
-
|
|
8622
|
-
|
|
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, {
|
|
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
|
-
|
|
1879
|
-
|
|
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
|
|
8118
|
-
|
|
8119
|
-
|
|
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, {
|
|
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.
|
|
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",
|