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