@cabane/companion 0.6.111 → 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 +222 -50
- package/dist/runtime.js +222 -50
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -2901,9 +2901,27 @@ function describeErrorBody(body) {
|
|
|
2901
2901
|
if (text.length === 0) return void 0;
|
|
2902
2902
|
return text.length > ERROR_BODY_LOG_CAP ? `${text.slice(0, ERROR_BODY_LOG_CAP)}\u2026` : text;
|
|
2903
2903
|
}
|
|
2904
|
+
var CAUSE_TEXT_CAP = 200;
|
|
2905
|
+
function describeError(err) {
|
|
2906
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2907
|
+
const cause = describeCause(err);
|
|
2908
|
+
return cause === void 0 ? message : `${message}: ${cause}`;
|
|
2909
|
+
}
|
|
2910
|
+
function describeCause(err) {
|
|
2911
|
+
if (!(err instanceof Error)) return void 0;
|
|
2912
|
+
const cause = err.cause;
|
|
2913
|
+
if (cause === void 0 || cause === null) return void 0;
|
|
2914
|
+
if (typeof cause === "object") {
|
|
2915
|
+
const code = cause.code;
|
|
2916
|
+
if (typeof code === "string" && code.length > 0) return code;
|
|
2917
|
+
}
|
|
2918
|
+
const text = cause instanceof Error ? cause.message : typeof cause === "string" ? cause : void 0;
|
|
2919
|
+
if (text === void 0 || text.length === 0) return void 0;
|
|
2920
|
+
return text.length > CAUSE_TEXT_CAP ? `${text.slice(0, CAUSE_TEXT_CAP)}\u2026` : text;
|
|
2921
|
+
}
|
|
2904
2922
|
function apiErrorLogFields(err) {
|
|
2905
2923
|
const fields = {
|
|
2906
|
-
err:
|
|
2924
|
+
err: describeError(err)
|
|
2907
2925
|
};
|
|
2908
2926
|
if (err instanceof ApiError) {
|
|
2909
2927
|
fields.status = err.status;
|
|
@@ -2922,6 +2940,7 @@ import { randomUUID as randomUUID4 } from "crypto";
|
|
|
2922
2940
|
|
|
2923
2941
|
// src/api.ts
|
|
2924
2942
|
var RETRY_BACKOFF_MS = [250, 750];
|
|
2943
|
+
var TURN_CONTEXT_BACKOFF_MS = [250, 750, 2e3, 5e3];
|
|
2925
2944
|
var ACTIVE_RUN_OUTBOX_SEQ = 0;
|
|
2926
2945
|
var LEASE_CHECK_TIMEOUT_MS = 1e4;
|
|
2927
2946
|
function activeRunOutboxKey(conversationId, agentId) {
|
|
@@ -2972,14 +2991,26 @@ var CabaneApi = class {
|
|
|
2972
2991
|
return parsed;
|
|
2973
2992
|
}
|
|
2974
2993
|
async request(method, path, body, opts = {}) {
|
|
2975
|
-
const { signal, retry =
|
|
2976
|
-
const maxAttempts = retry ?
|
|
2994
|
+
const { signal, retry = method === "GET", backoffMs = RETRY_BACKOFF_MS } = opts;
|
|
2995
|
+
const maxAttempts = retry ? backoffMs.length + 1 : 1;
|
|
2977
2996
|
for (let attempt = 1; ; attempt++) {
|
|
2978
2997
|
try {
|
|
2979
2998
|
return await this.attempt(method, path, body, signal);
|
|
2980
2999
|
} catch (err) {
|
|
2981
3000
|
if (attempt >= maxAttempts || signal?.aborted || !isRetryable(err)) throw err;
|
|
2982
|
-
|
|
3001
|
+
const delayMs = backoffMs[attempt - 1];
|
|
3002
|
+
this.opts.log?.info(
|
|
3003
|
+
{
|
|
3004
|
+
method,
|
|
3005
|
+
path: pathForLog(path),
|
|
3006
|
+
attempt,
|
|
3007
|
+
maxAttempts,
|
|
3008
|
+
delayMs,
|
|
3009
|
+
err: describeError(err)
|
|
3010
|
+
},
|
|
3011
|
+
"companion: API call failed transiently; retrying"
|
|
3012
|
+
);
|
|
3013
|
+
await sleep2(delayMs, signal);
|
|
2983
3014
|
if (signal?.aborted) throw err;
|
|
2984
3015
|
}
|
|
2985
3016
|
}
|
|
@@ -3117,9 +3148,16 @@ var CabaneApi = class {
|
|
|
3117
3148
|
// CT1543: `episode` is the dispatch event's own, echoed back so the server can
|
|
3118
3149
|
// tell this frame from a later wake on the same message. Omitted when the
|
|
3119
3150
|
// event didn't carry one, which is the only thing an older event can mean.
|
|
3120
|
-
|
|
3151
|
+
// CT1657: `signal` is the turn's own abort. It matters here more than on any
|
|
3152
|
+
// other read, because this call now waits up to ~8s across five attempts: a
|
|
3153
|
+
// Stop landing in that window used to leave the whole schedule running, and
|
|
3154
|
+
// the person watched a turn they had already stopped keep trying.
|
|
3155
|
+
getTurnContext(conversationId, messageId2, turnId, resumed, fresh, episode, signal) {
|
|
3121
3156
|
const q = `conversationId=${encodeURIComponent(conversationId)}&messageId=${encodeURIComponent(messageId2)}` + (turnId ? `&turnId=${encodeURIComponent(turnId)}` : "") + (resumed ? "&resumed=1" : "") + (fresh ? "&fresh=1" : "") + (episode !== void 0 ? `&episode=${encodeURIComponent(String(episode))}` : "");
|
|
3122
|
-
return this.request("GET", `/api/agent/turn-context?${q}
|
|
3157
|
+
return this.request("GET", `/api/agent/turn-context?${q}`, void 0, {
|
|
3158
|
+
backoffMs: this.opts.turnContextBackoffMs ?? TURN_CONTEXT_BACKOFF_MS,
|
|
3159
|
+
...signal ? { signal } : {}
|
|
3160
|
+
});
|
|
3123
3161
|
}
|
|
3124
3162
|
// CT1380: the local half of a resumed turn's seq floor (see
|
|
3125
3163
|
// `Outbox.maxSeqForTurn`). 0 when no outbox is configured — a one-shot CLI or
|
|
@@ -3204,6 +3242,12 @@ var CabaneApi = class {
|
|
|
3204
3242
|
`/api/workspaces/${workspaceId}/conversations/${conversationId}/participants/agents/${agentId}/turn-lease?${q}`,
|
|
3205
3243
|
void 0,
|
|
3206
3244
|
{
|
|
3245
|
+
// CT1657: the one GET that opts OUT of the default retry. The reasoning
|
|
3246
|
+
// is right above — a failed probe has no answer worth replaying and the
|
|
3247
|
+
// next tick asks again in seconds, so retrying inside this one would
|
|
3248
|
+
// stretch a tick over its successor's slot, which is what the deadline
|
|
3249
|
+
// exists to prevent.
|
|
3250
|
+
retry: false,
|
|
3207
3251
|
signal: AbortSignal.timeout(this.opts.leaseCheckTimeoutMs ?? LEASE_CHECK_TIMEOUT_MS)
|
|
3208
3252
|
}
|
|
3209
3253
|
);
|
|
@@ -3395,6 +3439,10 @@ function isRetryable(err) {
|
|
|
3395
3439
|
function isAbortError(err) {
|
|
3396
3440
|
return err instanceof Error && err.name === "AbortError";
|
|
3397
3441
|
}
|
|
3442
|
+
function pathForLog(path) {
|
|
3443
|
+
const q = path.indexOf("?");
|
|
3444
|
+
return q === -1 ? path : path.slice(0, q);
|
|
3445
|
+
}
|
|
3398
3446
|
function sleep2(ms, signal) {
|
|
3399
3447
|
return new Promise((resolve2) => {
|
|
3400
3448
|
if (signal?.aborted) return resolve2();
|
|
@@ -9569,6 +9617,7 @@ var TurnExecution = class {
|
|
|
9569
9617
|
// identity-checked at the supervisor — then clear the active-run flag);
|
|
9570
9618
|
// everything past the lease settles in `execute()`'s one `finally`.
|
|
9571
9619
|
async run() {
|
|
9620
|
+
this.supervisor.registerAbort(this.turnId, this.abortController);
|
|
9572
9621
|
try {
|
|
9573
9622
|
await this.fetchContext();
|
|
9574
9623
|
this.gateTrigger();
|
|
@@ -9590,6 +9639,8 @@ var TurnExecution = class {
|
|
|
9590
9639
|
this.supervisor.releaseAbort(this.turnId, this.abortController);
|
|
9591
9640
|
const msg = err instanceof Error ? err.message : String(err);
|
|
9592
9641
|
await this.concludeAdmittedRun("setup_failed", `setup_failed: ${msg}`);
|
|
9642
|
+
} else {
|
|
9643
|
+
this.supervisor.releaseAbort(this.turnId, this.abortController);
|
|
9593
9644
|
}
|
|
9594
9645
|
throw err;
|
|
9595
9646
|
}
|
|
@@ -9671,9 +9722,14 @@ var TurnExecution = class {
|
|
|
9671
9722
|
turnId,
|
|
9672
9723
|
this.resumed,
|
|
9673
9724
|
void 0,
|
|
9674
|
-
payload.episode
|
|
9725
|
+
payload.episode,
|
|
9726
|
+
this.abortController.signal
|
|
9675
9727
|
);
|
|
9676
9728
|
} catch (err) {
|
|
9729
|
+
if (this.abortController.signal.aborted) {
|
|
9730
|
+
turnLog.debug("dispatcher: stopped while loading the turn context");
|
|
9731
|
+
throw this.concluded("cancelled");
|
|
9732
|
+
}
|
|
9677
9733
|
const status2 = err instanceof ApiError ? err.status : 0;
|
|
9678
9734
|
if (status2 === 404) {
|
|
9679
9735
|
turnLog.debug(
|
|
@@ -9688,9 +9744,8 @@ var TurnExecution = class {
|
|
|
9688
9744
|
);
|
|
9689
9745
|
throw this.concluded("turn_context_not_admitted");
|
|
9690
9746
|
}
|
|
9691
|
-
|
|
9692
|
-
|
|
9693
|
-
const fetchReason = `fetch_failed: ${reason}`;
|
|
9747
|
+
turnLog.warn(apiErrorLogFields(err), "dispatcher: failed to fetch turn context");
|
|
9748
|
+
const fetchReason = `fetch_failed: ${describeError(err)}`;
|
|
9694
9749
|
throw this.concluded(fetchReason, fetchReason);
|
|
9695
9750
|
}
|
|
9696
9751
|
this.turnContext = turnContext;
|
|
@@ -10053,7 +10108,12 @@ var TurnExecution = class {
|
|
|
10053
10108
|
payload.messageId,
|
|
10054
10109
|
turnId,
|
|
10055
10110
|
false,
|
|
10056
|
-
true
|
|
10111
|
+
true,
|
|
10112
|
+
void 0,
|
|
10113
|
+
// CT1657: the turn's abort. This recompose happens mid-run with the
|
|
10114
|
+
// adapter waiting on it, so a Stop here must end the wait rather than
|
|
10115
|
+
// hold the turn open for the rest of the retry schedule.
|
|
10116
|
+
this.abortController.signal
|
|
10057
10117
|
);
|
|
10058
10118
|
turnLog.debug(
|
|
10059
10119
|
{ reason },
|
|
@@ -10065,6 +10125,10 @@ var TurnExecution = class {
|
|
|
10065
10125
|
content: context.content
|
|
10066
10126
|
};
|
|
10067
10127
|
} catch (err) {
|
|
10128
|
+
if (this.abortController.signal.aborted) {
|
|
10129
|
+
turnLog.debug({ reason }, "dispatcher: stopped while recomposing the turn context");
|
|
10130
|
+
return null;
|
|
10131
|
+
}
|
|
10068
10132
|
turnLog.error(
|
|
10069
10133
|
{ reason, ...apiErrorLogFields(err) },
|
|
10070
10134
|
"Couldn't load the full conversation; continuing with the recent messages."
|
|
@@ -10590,6 +10654,41 @@ async function enumerateOpencodeModels(serverUrl, fetchImpl = fetch) {
|
|
|
10590
10654
|
}
|
|
10591
10655
|
}
|
|
10592
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
|
+
|
|
10593
10692
|
// src/outbox.ts
|
|
10594
10693
|
import {
|
|
10595
10694
|
existsSync as existsSync12,
|
|
@@ -10936,6 +11035,13 @@ var DRAIN_BASE_MS = 1e3;
|
|
|
10936
11035
|
var DRAIN_MAX_MS = 3e4;
|
|
10937
11036
|
var DRAIN_IDLE_MS = 15e3;
|
|
10938
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
|
+
}
|
|
10939
11045
|
var CompanionSupervisor = class {
|
|
10940
11046
|
workspaces = /* @__PURE__ */ new Map();
|
|
10941
11047
|
config;
|
|
@@ -10994,6 +11100,9 @@ var CompanionSupervisor = class {
|
|
|
10994
11100
|
// turn's classified outcome here (per runtime); the heartbeat echoes it so the
|
|
10995
11101
|
// server's per-connector quota state survives a restart/race.
|
|
10996
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();
|
|
10997
11106
|
constructor(opts) {
|
|
10998
11107
|
this.config = opts.config;
|
|
10999
11108
|
this.log = opts.log;
|
|
@@ -11037,7 +11146,9 @@ var CompanionSupervisor = class {
|
|
|
11037
11146
|
if (ev.event === "assignments_changed") {
|
|
11038
11147
|
if (this.refreshing && this.inFlightRefresh) await this.inFlightRefresh;
|
|
11039
11148
|
await this.refreshAssignments();
|
|
11149
|
+
return;
|
|
11040
11150
|
}
|
|
11151
|
+
if (ev.event === "message") await this.handleDeviceMessage(ev);
|
|
11041
11152
|
},
|
|
11042
11153
|
onAuthFailure: () => {
|
|
11043
11154
|
this.log.error(
|
|
@@ -11548,17 +11659,75 @@ var CompanionSupervisor = class {
|
|
|
11548
11659
|
async handleWorkspaceMessage(wr, ev) {
|
|
11549
11660
|
if (ev.event !== "message") return;
|
|
11550
11661
|
this.hub.recordEvent(wr.workspaceId);
|
|
11551
|
-
|
|
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) {
|
|
11552
11697
|
try {
|
|
11553
|
-
|
|
11698
|
+
return JSON.parse(ev.data);
|
|
11554
11699
|
} catch (err) {
|
|
11555
11700
|
this.log.debug(
|
|
11556
11701
|
{ err: err instanceof Error ? err.message : String(err) },
|
|
11557
11702
|
"malformed SSE payload"
|
|
11558
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);
|
|
11559
11720
|
return;
|
|
11560
11721
|
}
|
|
11561
|
-
|
|
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) {
|
|
11562
11731
|
if (wire.type === "device:cancel_requested") {
|
|
11563
11732
|
const payload2 = {
|
|
11564
11733
|
type: "device:cancel_requested",
|
|
@@ -11578,17 +11747,14 @@ var CompanionSupervisor = class {
|
|
|
11578
11747
|
);
|
|
11579
11748
|
}
|
|
11580
11749
|
}
|
|
11581
|
-
if (
|
|
11750
|
+
if (cursorId) wr.cursor.settle(cursorId);
|
|
11582
11751
|
return;
|
|
11583
11752
|
}
|
|
11584
|
-
|
|
11585
|
-
|
|
11753
|
+
const payload = dispatchPayload(wire);
|
|
11754
|
+
if (!payload) {
|
|
11755
|
+
if (cursorId) wr.cursor.settle(cursorId);
|
|
11586
11756
|
return;
|
|
11587
11757
|
}
|
|
11588
|
-
const payload = {
|
|
11589
|
-
type: "device:dispatch_requested",
|
|
11590
|
-
...wire.payload
|
|
11591
|
-
};
|
|
11592
11758
|
if (this.deviceId && payload.deviceId && payload.deviceId !== this.deviceId) {
|
|
11593
11759
|
this.log.debug(
|
|
11594
11760
|
{
|
|
@@ -11599,7 +11765,7 @@ var CompanionSupervisor = class {
|
|
|
11599
11765
|
},
|
|
11600
11766
|
"companion: dispatch addressed to another device; ignoring"
|
|
11601
11767
|
);
|
|
11602
|
-
if (
|
|
11768
|
+
if (cursorId) wr.cursor.settle(cursorId);
|
|
11603
11769
|
return;
|
|
11604
11770
|
}
|
|
11605
11771
|
let agent = wr.agents.get(payload.agentId);
|
|
@@ -11607,16 +11773,16 @@ var CompanionSupervisor = class {
|
|
|
11607
11773
|
agent = await this.recoverRacedAgent(wr, payload);
|
|
11608
11774
|
if (!agent) {
|
|
11609
11775
|
if (this.deviceId && payload.deviceId === this.deviceId) {
|
|
11610
|
-
const reported = await this.reportUnrunnableDispatch(wr, payload);
|
|
11776
|
+
const reported = await this.reportUnrunnableDispatch(wr.workspaceId, payload);
|
|
11611
11777
|
if (!reported) return;
|
|
11612
11778
|
}
|
|
11613
|
-
if (
|
|
11779
|
+
if (cursorId) wr.cursor.settle(cursorId);
|
|
11614
11780
|
return;
|
|
11615
11781
|
}
|
|
11616
11782
|
}
|
|
11617
11783
|
const chainKey = `${payload.conversationId}|${payload.agentId}`;
|
|
11618
11784
|
const prev = wr.chains.get(chainKey) ?? Promise.resolve();
|
|
11619
|
-
const tail = prev.then(() => this.runDispatch(wr,
|
|
11785
|
+
const tail = prev.then(() => this.runDispatch(wr, { dispatchId: wire.id, cursorId }, payload, agent)).catch((err) => {
|
|
11620
11786
|
this.log.warn(
|
|
11621
11787
|
{
|
|
11622
11788
|
workspaceId: wr.workspaceId,
|
|
@@ -11643,7 +11809,7 @@ var CompanionSupervisor = class {
|
|
|
11643
11809
|
// dropped socket) and one more attempt a second later usually lands. Bounded
|
|
11644
11810
|
// hard — this runs inline on the event loop, and an unbounded wait here would
|
|
11645
11811
|
// wedge the workspace's whole stream.
|
|
11646
|
-
async reportUnrunnableDispatch(
|
|
11812
|
+
async reportUnrunnableDispatch(workspaceId, payload) {
|
|
11647
11813
|
if (!this.deviceApi) return false;
|
|
11648
11814
|
let lastErr;
|
|
11649
11815
|
for (const wait of this.unrunnableRetryDelaysMs) {
|
|
@@ -11651,7 +11817,7 @@ var CompanionSupervisor = class {
|
|
|
11651
11817
|
if (wait > 0) await new Promise((r) => setTimeout(r, wait));
|
|
11652
11818
|
try {
|
|
11653
11819
|
const { cleared } = await this.deviceApi.reportDispatchDrop({
|
|
11654
|
-
workspaceId
|
|
11820
|
+
workspaceId,
|
|
11655
11821
|
conversationId: payload.conversationId,
|
|
11656
11822
|
agentId: payload.agentId,
|
|
11657
11823
|
messageId: payload.messageId,
|
|
@@ -11659,7 +11825,7 @@ var CompanionSupervisor = class {
|
|
|
11659
11825
|
});
|
|
11660
11826
|
this.log.debug(
|
|
11661
11827
|
{
|
|
11662
|
-
workspaceId
|
|
11828
|
+
workspaceId,
|
|
11663
11829
|
conversationId: payload.conversationId,
|
|
11664
11830
|
agentId: payload.agentId,
|
|
11665
11831
|
cleared
|
|
@@ -11673,7 +11839,7 @@ var CompanionSupervisor = class {
|
|
|
11673
11839
|
}
|
|
11674
11840
|
this.log.error(
|
|
11675
11841
|
{
|
|
11676
|
-
workspaceId
|
|
11842
|
+
workspaceId,
|
|
11677
11843
|
conversationId: payload.conversationId,
|
|
11678
11844
|
agentId: payload.agentId,
|
|
11679
11845
|
err: lastErr instanceof Error ? lastErr.message : String(lastErr)
|
|
@@ -11728,7 +11894,10 @@ var CompanionSupervisor = class {
|
|
|
11728
11894
|
};
|
|
11729
11895
|
const tail = this.runDispatch(
|
|
11730
11896
|
wr,
|
|
11731
|
-
|
|
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: "" },
|
|
11732
11901
|
payload,
|
|
11733
11902
|
agent,
|
|
11734
11903
|
run.turnId
|
|
@@ -11752,56 +11921,59 @@ var CompanionSupervisor = class {
|
|
|
11752
11921
|
// One turn: the SJ516 F7 / CT26 skip-vs-resume dedupe, the dispatch, then
|
|
11753
11922
|
// cursor + console summary. Mirrors the old per-workspace `handleEvent`, now
|
|
11754
11923
|
// scoped to one agent.
|
|
11755
|
-
async runDispatch(wr,
|
|
11924
|
+
async runDispatch(wr, ids, payload, agent, recoveredTurnId) {
|
|
11756
11925
|
const workspaceId = wr.workspaceId;
|
|
11757
|
-
|
|
11758
|
-
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)) {
|
|
11759
11931
|
this.log.debug(
|
|
11760
|
-
{ workspaceId, eventId:
|
|
11932
|
+
{ workspaceId, eventId: ids.dispatchId },
|
|
11761
11933
|
"companion: skipping already-completed event (resume after restart)"
|
|
11762
11934
|
);
|
|
11763
|
-
|
|
11935
|
+
settleCursor();
|
|
11764
11936
|
return;
|
|
11765
11937
|
}
|
|
11766
11938
|
if (noResume()) {
|
|
11767
11939
|
this.log.debug(
|
|
11768
|
-
{ workspaceId, eventId:
|
|
11940
|
+
{ workspaceId, eventId: ids.dispatchId },
|
|
11769
11941
|
"companion: skipping interrupted turn (CABANE_COMPANION_NO_RESUME=1) \u2014 resume disabled for this boot"
|
|
11770
11942
|
);
|
|
11771
|
-
markCompleted(workspaceId,
|
|
11772
|
-
|
|
11943
|
+
markCompleted(workspaceId, ids.dispatchId);
|
|
11944
|
+
settleCursor();
|
|
11773
11945
|
return;
|
|
11774
11946
|
}
|
|
11775
|
-
const attempt = bumpResumeAttempt(workspaceId,
|
|
11947
|
+
const attempt = bumpResumeAttempt(workspaceId, ids.dispatchId);
|
|
11776
11948
|
if (attempt > MAX_RESUME_ATTEMPTS) {
|
|
11777
11949
|
this.log.error(
|
|
11778
|
-
{ workspaceId, eventId:
|
|
11950
|
+
{ workspaceId, eventId: ids.dispatchId, attempt, cap: MAX_RESUME_ATTEMPTS },
|
|
11779
11951
|
"An interrupted turn couldn't finish after several attempts. Reply in Cabane to try again."
|
|
11780
11952
|
);
|
|
11781
|
-
markCompleted(workspaceId,
|
|
11782
|
-
|
|
11953
|
+
markCompleted(workspaceId, ids.dispatchId);
|
|
11954
|
+
settleCursor();
|
|
11783
11955
|
return;
|
|
11784
11956
|
}
|
|
11785
11957
|
this.log.debug(
|
|
11786
|
-
{ workspaceId, eventId:
|
|
11958
|
+
{ workspaceId, eventId: ids.dispatchId, attempt, cap: MAX_RESUME_ATTEMPTS },
|
|
11787
11959
|
"companion: re-dispatching interrupted turn (resume after restart)"
|
|
11788
11960
|
);
|
|
11789
11961
|
}
|
|
11790
|
-
const resumedTurnId = recoveredTurnId ?? (
|
|
11962
|
+
const resumedTurnId = recoveredTurnId ?? (ids.dispatchId ? turnIdForEvent(workspaceId, ids.dispatchId) : null);
|
|
11791
11963
|
const turnId = resumedTurnId ?? randomUUID4();
|
|
11792
|
-
if (
|
|
11793
|
-
const liveTurnIds = rememberTurnId(workspaceId,
|
|
11964
|
+
if (ids.dispatchId) {
|
|
11965
|
+
const liveTurnIds = rememberTurnId(workspaceId, ids.dispatchId, turnId);
|
|
11794
11966
|
if (liveTurnIds > TURN_ID_OVERFLOW_WARN) {
|
|
11795
11967
|
this.log.debug(
|
|
11796
11968
|
{ workspaceId, liveTurnIds, threshold: TURN_ID_OVERFLOW_WARN },
|
|
11797
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."
|
|
11798
11970
|
);
|
|
11799
11971
|
}
|
|
11800
|
-
markDispatched(workspaceId,
|
|
11972
|
+
markDispatched(workspaceId, ids.dispatchId);
|
|
11801
11973
|
}
|
|
11802
11974
|
if (resumedTurnId) {
|
|
11803
11975
|
this.log.debug(
|
|
11804
|
-
{ workspaceId, eventId:
|
|
11976
|
+
{ workspaceId, eventId: ids.dispatchId, turnId },
|
|
11805
11977
|
"companion: resuming an interrupted turn under its original id"
|
|
11806
11978
|
);
|
|
11807
11979
|
}
|
|
@@ -11816,8 +11988,8 @@ var CompanionSupervisor = class {
|
|
|
11816
11988
|
turnId,
|
|
11817
11989
|
resumed: resumedTurnId !== null
|
|
11818
11990
|
});
|
|
11819
|
-
if (
|
|
11820
|
-
|
|
11991
|
+
if (ids.dispatchId) markCompleted(workspaceId, ids.dispatchId);
|
|
11992
|
+
settleCursor();
|
|
11821
11993
|
const durationS = (result.durationMs / 1e3).toFixed(1);
|
|
11822
11994
|
const bindings = {
|
|
11823
11995
|
...turnBindings,
|
package/dist/runtime.js
CHANGED
|
@@ -2235,9 +2235,27 @@ function describeErrorBody(body) {
|
|
|
2235
2235
|
if (text.length === 0) return void 0;
|
|
2236
2236
|
return text.length > ERROR_BODY_LOG_CAP ? `${text.slice(0, ERROR_BODY_LOG_CAP)}\u2026` : text;
|
|
2237
2237
|
}
|
|
2238
|
+
var CAUSE_TEXT_CAP = 200;
|
|
2239
|
+
function describeError(err) {
|
|
2240
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2241
|
+
const cause = describeCause(err);
|
|
2242
|
+
return cause === void 0 ? message : `${message}: ${cause}`;
|
|
2243
|
+
}
|
|
2244
|
+
function describeCause(err) {
|
|
2245
|
+
if (!(err instanceof Error)) return void 0;
|
|
2246
|
+
const cause = err.cause;
|
|
2247
|
+
if (cause === void 0 || cause === null) return void 0;
|
|
2248
|
+
if (typeof cause === "object") {
|
|
2249
|
+
const code = cause.code;
|
|
2250
|
+
if (typeof code === "string" && code.length > 0) return code;
|
|
2251
|
+
}
|
|
2252
|
+
const text = cause instanceof Error ? cause.message : typeof cause === "string" ? cause : void 0;
|
|
2253
|
+
if (text === void 0 || text.length === 0) return void 0;
|
|
2254
|
+
return text.length > CAUSE_TEXT_CAP ? `${text.slice(0, CAUSE_TEXT_CAP)}\u2026` : text;
|
|
2255
|
+
}
|
|
2238
2256
|
function apiErrorLogFields(err) {
|
|
2239
2257
|
const fields = {
|
|
2240
|
-
err:
|
|
2258
|
+
err: describeError(err)
|
|
2241
2259
|
};
|
|
2242
2260
|
if (err instanceof ApiError) {
|
|
2243
2261
|
fields.status = err.status;
|
|
@@ -2256,6 +2274,7 @@ import { randomUUID as randomUUID4 } from "crypto";
|
|
|
2256
2274
|
|
|
2257
2275
|
// src/api.ts
|
|
2258
2276
|
var RETRY_BACKOFF_MS = [250, 750];
|
|
2277
|
+
var TURN_CONTEXT_BACKOFF_MS = [250, 750, 2e3, 5e3];
|
|
2259
2278
|
var ACTIVE_RUN_OUTBOX_SEQ = 0;
|
|
2260
2279
|
var LEASE_CHECK_TIMEOUT_MS = 1e4;
|
|
2261
2280
|
function activeRunOutboxKey(conversationId, agentId) {
|
|
@@ -2306,14 +2325,26 @@ var CabaneApi = class {
|
|
|
2306
2325
|
return parsed;
|
|
2307
2326
|
}
|
|
2308
2327
|
async request(method, path, body, opts = {}) {
|
|
2309
|
-
const { signal, retry =
|
|
2310
|
-
const maxAttempts = retry ?
|
|
2328
|
+
const { signal, retry = method === "GET", backoffMs = RETRY_BACKOFF_MS } = opts;
|
|
2329
|
+
const maxAttempts = retry ? backoffMs.length + 1 : 1;
|
|
2311
2330
|
for (let attempt = 1; ; attempt++) {
|
|
2312
2331
|
try {
|
|
2313
2332
|
return await this.attempt(method, path, body, signal);
|
|
2314
2333
|
} catch (err) {
|
|
2315
2334
|
if (attempt >= maxAttempts || signal?.aborted || !isRetryable(err)) throw err;
|
|
2316
|
-
|
|
2335
|
+
const delayMs = backoffMs[attempt - 1];
|
|
2336
|
+
this.opts.log?.info(
|
|
2337
|
+
{
|
|
2338
|
+
method,
|
|
2339
|
+
path: pathForLog(path),
|
|
2340
|
+
attempt,
|
|
2341
|
+
maxAttempts,
|
|
2342
|
+
delayMs,
|
|
2343
|
+
err: describeError(err)
|
|
2344
|
+
},
|
|
2345
|
+
"companion: API call failed transiently; retrying"
|
|
2346
|
+
);
|
|
2347
|
+
await sleep(delayMs, signal);
|
|
2317
2348
|
if (signal?.aborted) throw err;
|
|
2318
2349
|
}
|
|
2319
2350
|
}
|
|
@@ -2451,9 +2482,16 @@ var CabaneApi = class {
|
|
|
2451
2482
|
// CT1543: `episode` is the dispatch event's own, echoed back so the server can
|
|
2452
2483
|
// tell this frame from a later wake on the same message. Omitted when the
|
|
2453
2484
|
// event didn't carry one, which is the only thing an older event can mean.
|
|
2454
|
-
|
|
2485
|
+
// CT1657: `signal` is the turn's own abort. It matters here more than on any
|
|
2486
|
+
// other read, because this call now waits up to ~8s across five attempts: a
|
|
2487
|
+
// Stop landing in that window used to leave the whole schedule running, and
|
|
2488
|
+
// the person watched a turn they had already stopped keep trying.
|
|
2489
|
+
getTurnContext(conversationId, messageId2, turnId, resumed, fresh, episode, signal) {
|
|
2455
2490
|
const q = `conversationId=${encodeURIComponent(conversationId)}&messageId=${encodeURIComponent(messageId2)}` + (turnId ? `&turnId=${encodeURIComponent(turnId)}` : "") + (resumed ? "&resumed=1" : "") + (fresh ? "&fresh=1" : "") + (episode !== void 0 ? `&episode=${encodeURIComponent(String(episode))}` : "");
|
|
2456
|
-
return this.request("GET", `/api/agent/turn-context?${q}
|
|
2491
|
+
return this.request("GET", `/api/agent/turn-context?${q}`, void 0, {
|
|
2492
|
+
backoffMs: this.opts.turnContextBackoffMs ?? TURN_CONTEXT_BACKOFF_MS,
|
|
2493
|
+
...signal ? { signal } : {}
|
|
2494
|
+
});
|
|
2457
2495
|
}
|
|
2458
2496
|
// CT1380: the local half of a resumed turn's seq floor (see
|
|
2459
2497
|
// `Outbox.maxSeqForTurn`). 0 when no outbox is configured — a one-shot CLI or
|
|
@@ -2538,6 +2576,12 @@ var CabaneApi = class {
|
|
|
2538
2576
|
`/api/workspaces/${workspaceId}/conversations/${conversationId}/participants/agents/${agentId}/turn-lease?${q}`,
|
|
2539
2577
|
void 0,
|
|
2540
2578
|
{
|
|
2579
|
+
// CT1657: the one GET that opts OUT of the default retry. The reasoning
|
|
2580
|
+
// is right above — a failed probe has no answer worth replaying and the
|
|
2581
|
+
// next tick asks again in seconds, so retrying inside this one would
|
|
2582
|
+
// stretch a tick over its successor's slot, which is what the deadline
|
|
2583
|
+
// exists to prevent.
|
|
2584
|
+
retry: false,
|
|
2541
2585
|
signal: AbortSignal.timeout(this.opts.leaseCheckTimeoutMs ?? LEASE_CHECK_TIMEOUT_MS)
|
|
2542
2586
|
}
|
|
2543
2587
|
);
|
|
@@ -2729,6 +2773,10 @@ function isRetryable(err) {
|
|
|
2729
2773
|
function isAbortError(err) {
|
|
2730
2774
|
return err instanceof Error && err.name === "AbortError";
|
|
2731
2775
|
}
|
|
2776
|
+
function pathForLog(path) {
|
|
2777
|
+
const q = path.indexOf("?");
|
|
2778
|
+
return q === -1 ? path : path.slice(0, q);
|
|
2779
|
+
}
|
|
2732
2780
|
function sleep(ms, signal) {
|
|
2733
2781
|
return new Promise((resolve2) => {
|
|
2734
2782
|
if (signal?.aborted) return resolve2();
|
|
@@ -8992,6 +9040,7 @@ var TurnExecution = class {
|
|
|
8992
9040
|
// identity-checked at the supervisor — then clear the active-run flag);
|
|
8993
9041
|
// everything past the lease settles in `execute()`'s one `finally`.
|
|
8994
9042
|
async run() {
|
|
9043
|
+
this.supervisor.registerAbort(this.turnId, this.abortController);
|
|
8995
9044
|
try {
|
|
8996
9045
|
await this.fetchContext();
|
|
8997
9046
|
this.gateTrigger();
|
|
@@ -9013,6 +9062,8 @@ var TurnExecution = class {
|
|
|
9013
9062
|
this.supervisor.releaseAbort(this.turnId, this.abortController);
|
|
9014
9063
|
const msg = err instanceof Error ? err.message : String(err);
|
|
9015
9064
|
await this.concludeAdmittedRun("setup_failed", `setup_failed: ${msg}`);
|
|
9065
|
+
} else {
|
|
9066
|
+
this.supervisor.releaseAbort(this.turnId, this.abortController);
|
|
9016
9067
|
}
|
|
9017
9068
|
throw err;
|
|
9018
9069
|
}
|
|
@@ -9094,9 +9145,14 @@ var TurnExecution = class {
|
|
|
9094
9145
|
turnId,
|
|
9095
9146
|
this.resumed,
|
|
9096
9147
|
void 0,
|
|
9097
|
-
payload.episode
|
|
9148
|
+
payload.episode,
|
|
9149
|
+
this.abortController.signal
|
|
9098
9150
|
);
|
|
9099
9151
|
} catch (err) {
|
|
9152
|
+
if (this.abortController.signal.aborted) {
|
|
9153
|
+
turnLog.debug("dispatcher: stopped while loading the turn context");
|
|
9154
|
+
throw this.concluded("cancelled");
|
|
9155
|
+
}
|
|
9100
9156
|
const status = err instanceof ApiError ? err.status : 0;
|
|
9101
9157
|
if (status === 404) {
|
|
9102
9158
|
turnLog.debug(
|
|
@@ -9111,9 +9167,8 @@ var TurnExecution = class {
|
|
|
9111
9167
|
);
|
|
9112
9168
|
throw this.concluded("turn_context_not_admitted");
|
|
9113
9169
|
}
|
|
9114
|
-
|
|
9115
|
-
|
|
9116
|
-
const fetchReason = `fetch_failed: ${reason}`;
|
|
9170
|
+
turnLog.warn(apiErrorLogFields(err), "dispatcher: failed to fetch turn context");
|
|
9171
|
+
const fetchReason = `fetch_failed: ${describeError(err)}`;
|
|
9117
9172
|
throw this.concluded(fetchReason, fetchReason);
|
|
9118
9173
|
}
|
|
9119
9174
|
this.turnContext = turnContext;
|
|
@@ -9476,7 +9531,12 @@ var TurnExecution = class {
|
|
|
9476
9531
|
payload.messageId,
|
|
9477
9532
|
turnId,
|
|
9478
9533
|
false,
|
|
9479
|
-
true
|
|
9534
|
+
true,
|
|
9535
|
+
void 0,
|
|
9536
|
+
// CT1657: the turn's abort. This recompose happens mid-run with the
|
|
9537
|
+
// adapter waiting on it, so a Stop here must end the wait rather than
|
|
9538
|
+
// hold the turn open for the rest of the retry schedule.
|
|
9539
|
+
this.abortController.signal
|
|
9480
9540
|
);
|
|
9481
9541
|
turnLog.debug(
|
|
9482
9542
|
{ reason },
|
|
@@ -9488,6 +9548,10 @@ var TurnExecution = class {
|
|
|
9488
9548
|
content: context.content
|
|
9489
9549
|
};
|
|
9490
9550
|
} catch (err) {
|
|
9551
|
+
if (this.abortController.signal.aborted) {
|
|
9552
|
+
turnLog.debug({ reason }, "dispatcher: stopped while recomposing the turn context");
|
|
9553
|
+
return null;
|
|
9554
|
+
}
|
|
9491
9555
|
turnLog.error(
|
|
9492
9556
|
{ reason, ...apiErrorLogFields(err) },
|
|
9493
9557
|
"Couldn't load the full conversation; continuing with the recent messages."
|
|
@@ -10013,6 +10077,41 @@ async function enumerateOpencodeModels(serverUrl, fetchImpl = fetch) {
|
|
|
10013
10077
|
}
|
|
10014
10078
|
}
|
|
10015
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
|
+
|
|
10016
10115
|
// src/outbox.ts
|
|
10017
10116
|
import {
|
|
10018
10117
|
existsSync as existsSync12,
|
|
@@ -10359,6 +10458,13 @@ var DRAIN_BASE_MS = 1e3;
|
|
|
10359
10458
|
var DRAIN_MAX_MS = 3e4;
|
|
10360
10459
|
var DRAIN_IDLE_MS = 15e3;
|
|
10361
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
|
+
}
|
|
10362
10468
|
var CompanionSupervisor = class {
|
|
10363
10469
|
workspaces = /* @__PURE__ */ new Map();
|
|
10364
10470
|
config;
|
|
@@ -10417,6 +10523,9 @@ var CompanionSupervisor = class {
|
|
|
10417
10523
|
// turn's classified outcome here (per runtime); the heartbeat echoes it so the
|
|
10418
10524
|
// server's per-connector quota state survives a restart/race.
|
|
10419
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();
|
|
10420
10529
|
constructor(opts) {
|
|
10421
10530
|
this.config = opts.config;
|
|
10422
10531
|
this.log = opts.log;
|
|
@@ -10460,7 +10569,9 @@ var CompanionSupervisor = class {
|
|
|
10460
10569
|
if (ev.event === "assignments_changed") {
|
|
10461
10570
|
if (this.refreshing && this.inFlightRefresh) await this.inFlightRefresh;
|
|
10462
10571
|
await this.refreshAssignments();
|
|
10572
|
+
return;
|
|
10463
10573
|
}
|
|
10574
|
+
if (ev.event === "message") await this.handleDeviceMessage(ev);
|
|
10464
10575
|
},
|
|
10465
10576
|
onAuthFailure: () => {
|
|
10466
10577
|
this.log.error(
|
|
@@ -10971,17 +11082,75 @@ var CompanionSupervisor = class {
|
|
|
10971
11082
|
async handleWorkspaceMessage(wr, ev) {
|
|
10972
11083
|
if (ev.event !== "message") return;
|
|
10973
11084
|
this.hub.recordEvent(wr.workspaceId);
|
|
10974
|
-
|
|
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) {
|
|
10975
11120
|
try {
|
|
10976
|
-
|
|
11121
|
+
return JSON.parse(ev.data);
|
|
10977
11122
|
} catch (err) {
|
|
10978
11123
|
this.log.debug(
|
|
10979
11124
|
{ err: err instanceof Error ? err.message : String(err) },
|
|
10980
11125
|
"malformed SSE payload"
|
|
10981
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);
|
|
10982
11143
|
return;
|
|
10983
11144
|
}
|
|
10984
|
-
|
|
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) {
|
|
10985
11154
|
if (wire.type === "device:cancel_requested") {
|
|
10986
11155
|
const payload2 = {
|
|
10987
11156
|
type: "device:cancel_requested",
|
|
@@ -11001,17 +11170,14 @@ var CompanionSupervisor = class {
|
|
|
11001
11170
|
);
|
|
11002
11171
|
}
|
|
11003
11172
|
}
|
|
11004
|
-
if (
|
|
11173
|
+
if (cursorId) wr.cursor.settle(cursorId);
|
|
11005
11174
|
return;
|
|
11006
11175
|
}
|
|
11007
|
-
|
|
11008
|
-
|
|
11176
|
+
const payload = dispatchPayload(wire);
|
|
11177
|
+
if (!payload) {
|
|
11178
|
+
if (cursorId) wr.cursor.settle(cursorId);
|
|
11009
11179
|
return;
|
|
11010
11180
|
}
|
|
11011
|
-
const payload = {
|
|
11012
|
-
type: "device:dispatch_requested",
|
|
11013
|
-
...wire.payload
|
|
11014
|
-
};
|
|
11015
11181
|
if (this.deviceId && payload.deviceId && payload.deviceId !== this.deviceId) {
|
|
11016
11182
|
this.log.debug(
|
|
11017
11183
|
{
|
|
@@ -11022,7 +11188,7 @@ var CompanionSupervisor = class {
|
|
|
11022
11188
|
},
|
|
11023
11189
|
"companion: dispatch addressed to another device; ignoring"
|
|
11024
11190
|
);
|
|
11025
|
-
if (
|
|
11191
|
+
if (cursorId) wr.cursor.settle(cursorId);
|
|
11026
11192
|
return;
|
|
11027
11193
|
}
|
|
11028
11194
|
let agent = wr.agents.get(payload.agentId);
|
|
@@ -11030,16 +11196,16 @@ var CompanionSupervisor = class {
|
|
|
11030
11196
|
agent = await this.recoverRacedAgent(wr, payload);
|
|
11031
11197
|
if (!agent) {
|
|
11032
11198
|
if (this.deviceId && payload.deviceId === this.deviceId) {
|
|
11033
|
-
const reported = await this.reportUnrunnableDispatch(wr, payload);
|
|
11199
|
+
const reported = await this.reportUnrunnableDispatch(wr.workspaceId, payload);
|
|
11034
11200
|
if (!reported) return;
|
|
11035
11201
|
}
|
|
11036
|
-
if (
|
|
11202
|
+
if (cursorId) wr.cursor.settle(cursorId);
|
|
11037
11203
|
return;
|
|
11038
11204
|
}
|
|
11039
11205
|
}
|
|
11040
11206
|
const chainKey = `${payload.conversationId}|${payload.agentId}`;
|
|
11041
11207
|
const prev = wr.chains.get(chainKey) ?? Promise.resolve();
|
|
11042
|
-
const tail = prev.then(() => this.runDispatch(wr,
|
|
11208
|
+
const tail = prev.then(() => this.runDispatch(wr, { dispatchId: wire.id, cursorId }, payload, agent)).catch((err) => {
|
|
11043
11209
|
this.log.warn(
|
|
11044
11210
|
{
|
|
11045
11211
|
workspaceId: wr.workspaceId,
|
|
@@ -11066,7 +11232,7 @@ var CompanionSupervisor = class {
|
|
|
11066
11232
|
// dropped socket) and one more attempt a second later usually lands. Bounded
|
|
11067
11233
|
// hard — this runs inline on the event loop, and an unbounded wait here would
|
|
11068
11234
|
// wedge the workspace's whole stream.
|
|
11069
|
-
async reportUnrunnableDispatch(
|
|
11235
|
+
async reportUnrunnableDispatch(workspaceId, payload) {
|
|
11070
11236
|
if (!this.deviceApi) return false;
|
|
11071
11237
|
let lastErr;
|
|
11072
11238
|
for (const wait of this.unrunnableRetryDelaysMs) {
|
|
@@ -11074,7 +11240,7 @@ var CompanionSupervisor = class {
|
|
|
11074
11240
|
if (wait > 0) await new Promise((r) => setTimeout(r, wait));
|
|
11075
11241
|
try {
|
|
11076
11242
|
const { cleared } = await this.deviceApi.reportDispatchDrop({
|
|
11077
|
-
workspaceId
|
|
11243
|
+
workspaceId,
|
|
11078
11244
|
conversationId: payload.conversationId,
|
|
11079
11245
|
agentId: payload.agentId,
|
|
11080
11246
|
messageId: payload.messageId,
|
|
@@ -11082,7 +11248,7 @@ var CompanionSupervisor = class {
|
|
|
11082
11248
|
});
|
|
11083
11249
|
this.log.debug(
|
|
11084
11250
|
{
|
|
11085
|
-
workspaceId
|
|
11251
|
+
workspaceId,
|
|
11086
11252
|
conversationId: payload.conversationId,
|
|
11087
11253
|
agentId: payload.agentId,
|
|
11088
11254
|
cleared
|
|
@@ -11096,7 +11262,7 @@ var CompanionSupervisor = class {
|
|
|
11096
11262
|
}
|
|
11097
11263
|
this.log.error(
|
|
11098
11264
|
{
|
|
11099
|
-
workspaceId
|
|
11265
|
+
workspaceId,
|
|
11100
11266
|
conversationId: payload.conversationId,
|
|
11101
11267
|
agentId: payload.agentId,
|
|
11102
11268
|
err: lastErr instanceof Error ? lastErr.message : String(lastErr)
|
|
@@ -11151,7 +11317,10 @@ var CompanionSupervisor = class {
|
|
|
11151
11317
|
};
|
|
11152
11318
|
const tail = this.runDispatch(
|
|
11153
11319
|
wr,
|
|
11154
|
-
|
|
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: "" },
|
|
11155
11324
|
payload,
|
|
11156
11325
|
agent,
|
|
11157
11326
|
run.turnId
|
|
@@ -11175,56 +11344,59 @@ var CompanionSupervisor = class {
|
|
|
11175
11344
|
// One turn: the SJ516 F7 / CT26 skip-vs-resume dedupe, the dispatch, then
|
|
11176
11345
|
// cursor + console summary. Mirrors the old per-workspace `handleEvent`, now
|
|
11177
11346
|
// scoped to one agent.
|
|
11178
|
-
async runDispatch(wr,
|
|
11347
|
+
async runDispatch(wr, ids, payload, agent, recoveredTurnId) {
|
|
11179
11348
|
const workspaceId = wr.workspaceId;
|
|
11180
|
-
|
|
11181
|
-
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)) {
|
|
11182
11354
|
this.log.debug(
|
|
11183
|
-
{ workspaceId, eventId:
|
|
11355
|
+
{ workspaceId, eventId: ids.dispatchId },
|
|
11184
11356
|
"companion: skipping already-completed event (resume after restart)"
|
|
11185
11357
|
);
|
|
11186
|
-
|
|
11358
|
+
settleCursor();
|
|
11187
11359
|
return;
|
|
11188
11360
|
}
|
|
11189
11361
|
if (noResume()) {
|
|
11190
11362
|
this.log.debug(
|
|
11191
|
-
{ workspaceId, eventId:
|
|
11363
|
+
{ workspaceId, eventId: ids.dispatchId },
|
|
11192
11364
|
"companion: skipping interrupted turn (CABANE_COMPANION_NO_RESUME=1) \u2014 resume disabled for this boot"
|
|
11193
11365
|
);
|
|
11194
|
-
markCompleted(workspaceId,
|
|
11195
|
-
|
|
11366
|
+
markCompleted(workspaceId, ids.dispatchId);
|
|
11367
|
+
settleCursor();
|
|
11196
11368
|
return;
|
|
11197
11369
|
}
|
|
11198
|
-
const attempt = bumpResumeAttempt(workspaceId,
|
|
11370
|
+
const attempt = bumpResumeAttempt(workspaceId, ids.dispatchId);
|
|
11199
11371
|
if (attempt > MAX_RESUME_ATTEMPTS) {
|
|
11200
11372
|
this.log.error(
|
|
11201
|
-
{ workspaceId, eventId:
|
|
11373
|
+
{ workspaceId, eventId: ids.dispatchId, attempt, cap: MAX_RESUME_ATTEMPTS },
|
|
11202
11374
|
"An interrupted turn couldn't finish after several attempts. Reply in Cabane to try again."
|
|
11203
11375
|
);
|
|
11204
|
-
markCompleted(workspaceId,
|
|
11205
|
-
|
|
11376
|
+
markCompleted(workspaceId, ids.dispatchId);
|
|
11377
|
+
settleCursor();
|
|
11206
11378
|
return;
|
|
11207
11379
|
}
|
|
11208
11380
|
this.log.debug(
|
|
11209
|
-
{ workspaceId, eventId:
|
|
11381
|
+
{ workspaceId, eventId: ids.dispatchId, attempt, cap: MAX_RESUME_ATTEMPTS },
|
|
11210
11382
|
"companion: re-dispatching interrupted turn (resume after restart)"
|
|
11211
11383
|
);
|
|
11212
11384
|
}
|
|
11213
|
-
const resumedTurnId = recoveredTurnId ?? (
|
|
11385
|
+
const resumedTurnId = recoveredTurnId ?? (ids.dispatchId ? turnIdForEvent(workspaceId, ids.dispatchId) : null);
|
|
11214
11386
|
const turnId = resumedTurnId ?? randomUUID4();
|
|
11215
|
-
if (
|
|
11216
|
-
const liveTurnIds = rememberTurnId(workspaceId,
|
|
11387
|
+
if (ids.dispatchId) {
|
|
11388
|
+
const liveTurnIds = rememberTurnId(workspaceId, ids.dispatchId, turnId);
|
|
11217
11389
|
if (liveTurnIds > TURN_ID_OVERFLOW_WARN) {
|
|
11218
11390
|
this.log.debug(
|
|
11219
11391
|
{ workspaceId, liveTurnIds, threshold: TURN_ID_OVERFLOW_WARN },
|
|
11220
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."
|
|
11221
11393
|
);
|
|
11222
11394
|
}
|
|
11223
|
-
markDispatched(workspaceId,
|
|
11395
|
+
markDispatched(workspaceId, ids.dispatchId);
|
|
11224
11396
|
}
|
|
11225
11397
|
if (resumedTurnId) {
|
|
11226
11398
|
this.log.debug(
|
|
11227
|
-
{ workspaceId, eventId:
|
|
11399
|
+
{ workspaceId, eventId: ids.dispatchId, turnId },
|
|
11228
11400
|
"companion: resuming an interrupted turn under its original id"
|
|
11229
11401
|
);
|
|
11230
11402
|
}
|
|
@@ -11239,8 +11411,8 @@ var CompanionSupervisor = class {
|
|
|
11239
11411
|
turnId,
|
|
11240
11412
|
resumed: resumedTurnId !== null
|
|
11241
11413
|
});
|
|
11242
|
-
if (
|
|
11243
|
-
|
|
11414
|
+
if (ids.dispatchId) markCompleted(workspaceId, ids.dispatchId);
|
|
11415
|
+
settleCursor();
|
|
11244
11416
|
const durationS = (result.durationMs / 1e3).toFixed(1);
|
|
11245
11417
|
const bindings = {
|
|
11246
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",
|