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