@cabane/companion 0.6.63 → 0.6.64
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 +165 -23
- package/dist/runtime.js +165 -23
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1735,7 +1735,7 @@ function getLogger() {
|
|
|
1735
1735
|
}
|
|
1736
1736
|
|
|
1737
1737
|
// src/runtime.ts
|
|
1738
|
-
import { randomUUID as
|
|
1738
|
+
import { randomUUID as randomUUID3 } from "crypto";
|
|
1739
1739
|
|
|
1740
1740
|
// src/dashboard/server.ts
|
|
1741
1741
|
import { dirname as dirname4, join as join7 } from "path";
|
|
@@ -2278,6 +2278,9 @@ async function warnAboutHarnessReadiness(cfg, deps = {}) {
|
|
|
2278
2278
|
);
|
|
2279
2279
|
}
|
|
2280
2280
|
|
|
2281
|
+
// src/supervisor.ts
|
|
2282
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
2283
|
+
|
|
2281
2284
|
// src/api.ts
|
|
2282
2285
|
var RETRY_BACKOFF_MS = [250, 750];
|
|
2283
2286
|
var ACTIVE_RUN_OUTBOX_SEQ = 0;
|
|
@@ -2522,12 +2525,20 @@ var CabaneApi = class {
|
|
|
2522
2525
|
// `durableActiveRunWrite`). The session-id-only write (first-frame capture) is
|
|
2523
2526
|
// left best-effort: it's lower-stakes and self-heals on the next turn, so it
|
|
2524
2527
|
// stays a single-shot PATCH and is deliberately out of CT93's scope.
|
|
2525
|
-
setActiveRun(workspaceId, conversationId, agentId, body) {
|
|
2528
|
+
async setActiveRun(workspaceId, conversationId, agentId, body) {
|
|
2526
2529
|
const path = `/api/workspaces/${workspaceId}/conversations/${conversationId}/participants/agents/${agentId}/active-run`;
|
|
2527
2530
|
const touchesFlag = Object.prototype.hasOwnProperty.call(body, "activeRunStartedAt");
|
|
2528
|
-
|
|
2531
|
+
const startsRun = touchesFlag && body.activeRunStartedAt !== null && body.activeRunStartedAt !== void 0;
|
|
2532
|
+
if (touchesFlag && !startsRun && this.opts.outbox) {
|
|
2529
2533
|
return this.durableActiveRunWrite(path, conversationId, agentId, body);
|
|
2530
2534
|
}
|
|
2535
|
+
if (startsRun) {
|
|
2536
|
+
const res = await this.request("PATCH", path, body, {
|
|
2537
|
+
retry: true
|
|
2538
|
+
});
|
|
2539
|
+
this.opts.outbox?.remove(activeRunOutboxKey(conversationId, agentId), ACTIVE_RUN_OUTBOX_SEQ);
|
|
2540
|
+
return res;
|
|
2541
|
+
}
|
|
2531
2542
|
return this.request("PATCH", path, body);
|
|
2532
2543
|
}
|
|
2533
2544
|
// CT93: send-or-enqueue for an active-run flag write, with last-writer-wins
|
|
@@ -2819,7 +2830,7 @@ var CursorTracker = class {
|
|
|
2819
2830
|
};
|
|
2820
2831
|
|
|
2821
2832
|
// src/dispatch-dedupe.ts
|
|
2822
|
-
import { mkdirSync as mkdirSync7, readFileSync as readFileSync5, writeFileSync as writeFileSync5, existsSync as existsSync7 } from "fs";
|
|
2833
|
+
import { mkdirSync as mkdirSync7, readFileSync as readFileSync5, writeFileSync as writeFileSync5, renameSync as renameSync3, existsSync as existsSync7 } from "fs";
|
|
2823
2834
|
import { join as join9 } from "path";
|
|
2824
2835
|
var MAX_IDS = 256;
|
|
2825
2836
|
function dir(log) {
|
|
@@ -2859,6 +2870,7 @@ function hasCompleted(workspaceId, eventId) {
|
|
|
2859
2870
|
}
|
|
2860
2871
|
function markCompleted(workspaceId, eventId) {
|
|
2861
2872
|
mark("completed", workspaceId, eventId);
|
|
2873
|
+
forgetTurnId(workspaceId, eventId);
|
|
2862
2874
|
}
|
|
2863
2875
|
var MAX_RESUME_ATTEMPTS = 3;
|
|
2864
2876
|
function resumeDir() {
|
|
@@ -2900,6 +2912,56 @@ function bumpResumeAttempt(workspaceId, eventId) {
|
|
|
2900
2912
|
);
|
|
2901
2913
|
return next;
|
|
2902
2914
|
}
|
|
2915
|
+
function turnDir() {
|
|
2916
|
+
return join9(cabaneDir(), "turns");
|
|
2917
|
+
}
|
|
2918
|
+
function turnPathFor(workspaceId) {
|
|
2919
|
+
return join9(turnDir(), encodeURIComponent(workspaceId));
|
|
2920
|
+
}
|
|
2921
|
+
var TURN_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
2922
|
+
function readTurnIds(workspaceId) {
|
|
2923
|
+
const out = /* @__PURE__ */ new Map();
|
|
2924
|
+
const path = turnPathFor(workspaceId);
|
|
2925
|
+
if (!existsSync7(path)) return out;
|
|
2926
|
+
try {
|
|
2927
|
+
for (const line of readFileSync5(path, "utf8").split("\n")) {
|
|
2928
|
+
const trimmed = line.trim();
|
|
2929
|
+
if (!trimmed) continue;
|
|
2930
|
+
const tab = trimmed.lastIndexOf(" ");
|
|
2931
|
+
if (tab <= 0) continue;
|
|
2932
|
+
const id = trimmed.slice(0, tab);
|
|
2933
|
+
const turnId = trimmed.slice(tab + 1);
|
|
2934
|
+
if (id && TURN_ID_RE.test(turnId)) out.set(id, turnId);
|
|
2935
|
+
}
|
|
2936
|
+
} catch {
|
|
2937
|
+
return out;
|
|
2938
|
+
}
|
|
2939
|
+
return out;
|
|
2940
|
+
}
|
|
2941
|
+
function turnIdForEvent(workspaceId, eventId) {
|
|
2942
|
+
return readTurnIds(workspaceId).get(eventId) ?? null;
|
|
2943
|
+
}
|
|
2944
|
+
var TURN_ID_OVERFLOW_WARN = 8192;
|
|
2945
|
+
function writeTurnIds(workspaceId, turns) {
|
|
2946
|
+
const entries = [...turns.entries()];
|
|
2947
|
+
mkdirSync7(turnDir(), { recursive: true });
|
|
2948
|
+
const path = turnPathFor(workspaceId);
|
|
2949
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
2950
|
+
writeFileSync5(tmp, entries.map(([id, t]) => `${id} ${t}`).join("\n") + "\n", "utf8");
|
|
2951
|
+
renameSync3(tmp, path);
|
|
2952
|
+
return entries.length;
|
|
2953
|
+
}
|
|
2954
|
+
function rememberTurnId(workspaceId, eventId, turnId) {
|
|
2955
|
+
const turns = readTurnIds(workspaceId);
|
|
2956
|
+
if (turns.get(eventId) === turnId) return turns.size;
|
|
2957
|
+
turns.set(eventId, turnId);
|
|
2958
|
+
return writeTurnIds(workspaceId, turns);
|
|
2959
|
+
}
|
|
2960
|
+
function forgetTurnId(workspaceId, eventId) {
|
|
2961
|
+
const turns = readTurnIds(workspaceId);
|
|
2962
|
+
if (!turns.delete(eventId)) return;
|
|
2963
|
+
writeTurnIds(workspaceId, turns);
|
|
2964
|
+
}
|
|
2903
2965
|
function noResume() {
|
|
2904
2966
|
return process.env.CABANE_COMPANION_NO_RESUME === "1";
|
|
2905
2967
|
}
|
|
@@ -8040,6 +8102,18 @@ function checkoutState(cwd) {
|
|
|
8040
8102
|
function runKey(conversationId, agentId) {
|
|
8041
8103
|
return `${conversationId}|${agentId}`;
|
|
8042
8104
|
}
|
|
8105
|
+
var LEASE_REFUSALS = /* @__PURE__ */ new Set([
|
|
8106
|
+
"dispatch_not_admitted",
|
|
8107
|
+
"turn_already_ended",
|
|
8108
|
+
"turn_belongs_elsewhere"
|
|
8109
|
+
]);
|
|
8110
|
+
function leaseRefusal(err) {
|
|
8111
|
+
if (!(err instanceof ApiError)) return null;
|
|
8112
|
+
const body = err.body;
|
|
8113
|
+
const code = typeof body === "object" && body !== null && typeof body.error === "string" ? body.error : null;
|
|
8114
|
+
if (code && LEASE_REFUSALS.has(code)) return code;
|
|
8115
|
+
return null;
|
|
8116
|
+
}
|
|
8043
8117
|
var ERROR_BODY_LOG_CAP = 2e3;
|
|
8044
8118
|
function describeErrorBody(body) {
|
|
8045
8119
|
if (body === void 0 || body === null) return void 0;
|
|
@@ -8074,6 +8148,16 @@ var Dispatcher = class {
|
|
|
8074
8148
|
}
|
|
8075
8149
|
opts;
|
|
8076
8150
|
// SJ383: per-(conversation, agent) abort registry.
|
|
8151
|
+
// CT1288: RunKey -> (turnId -> controller). This used to be one controller
|
|
8152
|
+
// per (conversation, agent), which quietly encoded the invariant the whole
|
|
8153
|
+
// task exists to enforce: that a pair can only ever have one live loop. When
|
|
8154
|
+
// that assumption broke, the second `set` EVICTED the first controller and
|
|
8155
|
+
// the first loop became permanently uncancellable — no other code path can
|
|
8156
|
+
// reach into a running turn. So the registry that Stop depends on failed
|
|
8157
|
+
// exactly when Stop was the thing you needed.
|
|
8158
|
+
//
|
|
8159
|
+
// Nesting by turn id costs nothing in the normal single-turn case and makes
|
|
8160
|
+
// `cancel` total: it aborts every loop under the pair, not the newest one.
|
|
8077
8161
|
aborts;
|
|
8078
8162
|
// CT1109: pairs already told, in the conversation, that their session state is
|
|
8079
8163
|
// being refused. The failure repeats every single turn until someone fixes the
|
|
@@ -8136,7 +8220,12 @@ var Dispatcher = class {
|
|
|
8136
8220
|
this.notifyEnd({ id: payload.messageId, ok: false, durationMs, reason });
|
|
8137
8221
|
return { ok: false, durationMs, reason };
|
|
8138
8222
|
}
|
|
8139
|
-
|
|
8223
|
+
// CT1288: `opts.turnId` is a RESUMED turn id — the id this event already ran
|
|
8224
|
+
// under before the companion died. Passing it makes the replay ask the
|
|
8225
|
+
// mailroom to re-admit the turn it already owns, which the server explicitly
|
|
8226
|
+
// supports ("a replay reports, it never re-points"). Omitted on a fresh
|
|
8227
|
+
// dispatch, where minting below is correct.
|
|
8228
|
+
async handle(payload, opts = {}) {
|
|
8140
8229
|
const startedAt = Date.now();
|
|
8141
8230
|
const dispatchId = payload.messageId;
|
|
8142
8231
|
const workspaceId = this.opts.workspaceId;
|
|
@@ -8146,7 +8235,7 @@ var Dispatcher = class {
|
|
|
8146
8235
|
agentId: payload.agentId,
|
|
8147
8236
|
messageId: payload.messageId
|
|
8148
8237
|
});
|
|
8149
|
-
const turnId = randomUUID();
|
|
8238
|
+
const turnId = opts.turnId ?? randomUUID();
|
|
8150
8239
|
let turnContext;
|
|
8151
8240
|
try {
|
|
8152
8241
|
turnContext = await this.opts.api.getTurnContext(
|
|
@@ -8368,7 +8457,18 @@ ${reason}`,
|
|
|
8368
8457
|
};
|
|
8369
8458
|
const key = runKey(payload.conversationId, payload.agentId);
|
|
8370
8459
|
const abortController = new AbortController();
|
|
8371
|
-
this.aborts.
|
|
8460
|
+
let pairAborts = this.aborts.get(key);
|
|
8461
|
+
if (!pairAborts) {
|
|
8462
|
+
pairAborts = /* @__PURE__ */ new Map();
|
|
8463
|
+
this.aborts.set(key, pairAborts);
|
|
8464
|
+
}
|
|
8465
|
+
pairAborts.set(turnId, abortController);
|
|
8466
|
+
const releaseAbort = () => {
|
|
8467
|
+
const pair2 = this.aborts.get(key);
|
|
8468
|
+
if (pair2?.get(turnId) !== abortController) return;
|
|
8469
|
+
pair2.delete(turnId);
|
|
8470
|
+
if (pair2.size === 0) this.aborts.delete(key);
|
|
8471
|
+
};
|
|
8372
8472
|
let timeoutReason = null;
|
|
8373
8473
|
try {
|
|
8374
8474
|
await this.opts.api.setActiveRun(workspaceId, payload.conversationId, payload.agentId, {
|
|
@@ -8381,10 +8481,21 @@ ${reason}`,
|
|
|
8381
8481
|
turnId
|
|
8382
8482
|
});
|
|
8383
8483
|
} catch (err) {
|
|
8384
|
-
|
|
8385
|
-
|
|
8386
|
-
|
|
8484
|
+
const refusal = leaseRefusal(err);
|
|
8485
|
+
releaseAbort();
|
|
8486
|
+
if (refusal === "dispatch_not_admitted" || refusal === "turn_already_ended") {
|
|
8487
|
+
turnLog.error(
|
|
8488
|
+
{ refusal, turnId },
|
|
8489
|
+
"dispatcher: refused a turn lease; not running the model"
|
|
8490
|
+
);
|
|
8491
|
+
return this.concludeBeforeRun(payload, turnLog, startedAt, `lease_refused: ${refusal}`);
|
|
8492
|
+
}
|
|
8493
|
+
const reason = refusal ? `lease_refused: ${refusal}` : "lease_unconfirmed";
|
|
8494
|
+
turnLog.error(
|
|
8495
|
+
{ refusal, turnId, err: err instanceof Error ? err.message : String(err) },
|
|
8496
|
+
"dispatcher: turn lease not confirmed; not running the model"
|
|
8387
8497
|
);
|
|
8498
|
+
return this.concludeBeforeRun(payload, turnLog, startedAt, reason, reason);
|
|
8388
8499
|
}
|
|
8389
8500
|
const sendState = createSendState();
|
|
8390
8501
|
const skipState = createSkipState();
|
|
@@ -8488,6 +8599,7 @@ ${reason}`,
|
|
|
8488
8599
|
"dispatcher: runtime-unavailable notice post failed"
|
|
8489
8600
|
);
|
|
8490
8601
|
}
|
|
8602
|
+
releaseAbort();
|
|
8491
8603
|
return this.concludeBeforeRun(
|
|
8492
8604
|
payload,
|
|
8493
8605
|
turnLog,
|
|
@@ -8887,9 +8999,7 @@ ${reason}`,
|
|
|
8887
8999
|
"dispatcher: active-run clear failed terminally; server age-sweep is the backstop"
|
|
8888
9000
|
);
|
|
8889
9001
|
}
|
|
8890
|
-
|
|
8891
|
-
this.aborts.delete(key);
|
|
8892
|
-
}
|
|
9002
|
+
releaseAbort();
|
|
8893
9003
|
}
|
|
8894
9004
|
const durationMs = Date.now() - startedAt;
|
|
8895
9005
|
const finalReplyBody = committer.replyBody;
|
|
@@ -8933,11 +9043,13 @@ ${reason}`,
|
|
|
8933
9043
|
// THIS companion process. Returns true if an in-flight run was aborted.
|
|
8934
9044
|
cancel(conversationId, agentId) {
|
|
8935
9045
|
const key = runKey(conversationId, agentId);
|
|
8936
|
-
const
|
|
8937
|
-
if (!
|
|
8938
|
-
|
|
8939
|
-
|
|
8940
|
-
|
|
9046
|
+
const pair2 = this.aborts.get(key);
|
|
9047
|
+
if (!pair2 || pair2.size === 0) return false;
|
|
9048
|
+
for (const ac of [...pair2.values()]) {
|
|
9049
|
+
try {
|
|
9050
|
+
ac.abort();
|
|
9051
|
+
} catch {
|
|
9052
|
+
}
|
|
8941
9053
|
}
|
|
8942
9054
|
return true;
|
|
8943
9055
|
}
|
|
@@ -9001,7 +9113,7 @@ import {
|
|
|
9001
9113
|
mkdirSync as mkdirSync10,
|
|
9002
9114
|
readdirSync as readdirSync3,
|
|
9003
9115
|
readFileSync as readFileSync8,
|
|
9004
|
-
renameSync as
|
|
9116
|
+
renameSync as renameSync4,
|
|
9005
9117
|
rmSync as rmSync7,
|
|
9006
9118
|
writeFileSync as writeFileSync7
|
|
9007
9119
|
} from "fs";
|
|
@@ -9033,7 +9145,7 @@ var Outbox = class {
|
|
|
9033
9145
|
const tmp = `${target}.${process.pid}.tmp`;
|
|
9034
9146
|
try {
|
|
9035
9147
|
writeFileSync7(tmp, JSON.stringify(entry) + "\n", "utf8");
|
|
9036
|
-
|
|
9148
|
+
renameSync4(tmp, target);
|
|
9037
9149
|
} catch (err) {
|
|
9038
9150
|
try {
|
|
9039
9151
|
rmSync7(tmp, { force: true });
|
|
@@ -9834,6 +9946,19 @@ var CompanionSupervisor = class {
|
|
|
9834
9946
|
type: "device:dispatch_requested",
|
|
9835
9947
|
...wire.payload
|
|
9836
9948
|
};
|
|
9949
|
+
if (this.deviceId && payload.deviceId && payload.deviceId !== this.deviceId) {
|
|
9950
|
+
this.log.debug(
|
|
9951
|
+
{
|
|
9952
|
+
workspaceId: wr.workspaceId,
|
|
9953
|
+
conversationId: payload.conversationId,
|
|
9954
|
+
agentId: payload.agentId,
|
|
9955
|
+
addressedTo: payload.deviceId
|
|
9956
|
+
},
|
|
9957
|
+
"companion: dispatch addressed to another device; ignoring"
|
|
9958
|
+
);
|
|
9959
|
+
if (ev.id) wr.cursor.settle(ev.id);
|
|
9960
|
+
return;
|
|
9961
|
+
}
|
|
9837
9962
|
let agent = wr.agents.get(payload.agentId);
|
|
9838
9963
|
if (!agent) {
|
|
9839
9964
|
agent = await this.recoverRacedAgent(wr, payload);
|
|
@@ -9965,8 +10090,25 @@ var CompanionSupervisor = class {
|
|
|
9965
10090
|
"companion: re-dispatching interrupted turn (resume after restart)"
|
|
9966
10091
|
);
|
|
9967
10092
|
}
|
|
9968
|
-
|
|
9969
|
-
const
|
|
10093
|
+
const resumedTurnId = ev.id ? turnIdForEvent(workspaceId, ev.id) : null;
|
|
10094
|
+
const turnId = resumedTurnId ?? randomUUID2();
|
|
10095
|
+
if (ev.id) {
|
|
10096
|
+
const liveTurnIds = rememberTurnId(workspaceId, ev.id, turnId);
|
|
10097
|
+
if (liveTurnIds > TURN_ID_OVERFLOW_WARN) {
|
|
10098
|
+
this.log.error(
|
|
10099
|
+
{ workspaceId, liveTurnIds, threshold: TURN_ID_OVERFLOW_WARN },
|
|
10100
|
+
"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."
|
|
10101
|
+
);
|
|
10102
|
+
}
|
|
10103
|
+
markDispatched(workspaceId, ev.id);
|
|
10104
|
+
}
|
|
10105
|
+
if (resumedTurnId) {
|
|
10106
|
+
this.log.info(
|
|
10107
|
+
{ workspaceId, eventId: ev.id, turnId },
|
|
10108
|
+
"companion: resuming an interrupted turn under its original id"
|
|
10109
|
+
);
|
|
10110
|
+
}
|
|
10111
|
+
const result = await agent.dispatcher.handle(payload, { turnId });
|
|
9970
10112
|
if (ev.id) markCompleted(workspaceId, ev.id);
|
|
9971
10113
|
if (ev.id) wr.cursor.settle(ev.id);
|
|
9972
10114
|
const durationS = (result.durationMs / 1e3).toFixed(1);
|
|
@@ -10357,7 +10499,7 @@ async function createCompanionRuntime(opts = {}) {
|
|
|
10357
10499
|
opencodeServerUrl: cfg.opencode?.serverUrl,
|
|
10358
10500
|
codex: isCodexEnabled(cfg)
|
|
10359
10501
|
});
|
|
10360
|
-
const instanceId =
|
|
10502
|
+
const instanceId = randomUUID3();
|
|
10361
10503
|
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
10362
10504
|
const pre = readLiveRuntimeState();
|
|
10363
10505
|
if (pre && await verifyRuntime(pre) === "stale") clearRuntimeState();
|
package/dist/runtime.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// src/runtime.ts
|
|
2
|
-
import { randomUUID as
|
|
2
|
+
import { randomUUID as randomUUID3 } from "crypto";
|
|
3
3
|
|
|
4
4
|
// src/config.ts
|
|
5
5
|
import {
|
|
@@ -1698,6 +1698,9 @@ async function verifyRuntime(state, requestImpl = controlRequest) {
|
|
|
1698
1698
|
return body.instance_id === state.instanceId ? "ours" : "stale";
|
|
1699
1699
|
}
|
|
1700
1700
|
|
|
1701
|
+
// src/supervisor.ts
|
|
1702
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
1703
|
+
|
|
1701
1704
|
// src/api.ts
|
|
1702
1705
|
var RETRY_BACKOFF_MS = [250, 750];
|
|
1703
1706
|
var ACTIVE_RUN_OUTBOX_SEQ = 0;
|
|
@@ -1942,12 +1945,20 @@ var CabaneApi = class {
|
|
|
1942
1945
|
// `durableActiveRunWrite`). The session-id-only write (first-frame capture) is
|
|
1943
1946
|
// left best-effort: it's lower-stakes and self-heals on the next turn, so it
|
|
1944
1947
|
// stays a single-shot PATCH and is deliberately out of CT93's scope.
|
|
1945
|
-
setActiveRun(workspaceId, conversationId, agentId, body) {
|
|
1948
|
+
async setActiveRun(workspaceId, conversationId, agentId, body) {
|
|
1946
1949
|
const path = `/api/workspaces/${workspaceId}/conversations/${conversationId}/participants/agents/${agentId}/active-run`;
|
|
1947
1950
|
const touchesFlag = Object.prototype.hasOwnProperty.call(body, "activeRunStartedAt");
|
|
1948
|
-
|
|
1951
|
+
const startsRun = touchesFlag && body.activeRunStartedAt !== null && body.activeRunStartedAt !== void 0;
|
|
1952
|
+
if (touchesFlag && !startsRun && this.opts.outbox) {
|
|
1949
1953
|
return this.durableActiveRunWrite(path, conversationId, agentId, body);
|
|
1950
1954
|
}
|
|
1955
|
+
if (startsRun) {
|
|
1956
|
+
const res = await this.request("PATCH", path, body, {
|
|
1957
|
+
retry: true
|
|
1958
|
+
});
|
|
1959
|
+
this.opts.outbox?.remove(activeRunOutboxKey(conversationId, agentId), ACTIVE_RUN_OUTBOX_SEQ);
|
|
1960
|
+
return res;
|
|
1961
|
+
}
|
|
1951
1962
|
return this.request("PATCH", path, body);
|
|
1952
1963
|
}
|
|
1953
1964
|
// CT93: send-or-enqueue for an active-run flag write, with last-writer-wins
|
|
@@ -2318,7 +2329,7 @@ var CursorTracker = class {
|
|
|
2318
2329
|
};
|
|
2319
2330
|
|
|
2320
2331
|
// src/dispatch-dedupe.ts
|
|
2321
|
-
import { mkdirSync as mkdirSync7, readFileSync as readFileSync5, writeFileSync as writeFileSync5, existsSync as existsSync7 } from "fs";
|
|
2332
|
+
import { mkdirSync as mkdirSync7, readFileSync as readFileSync5, writeFileSync as writeFileSync5, renameSync as renameSync3, existsSync as existsSync7 } from "fs";
|
|
2322
2333
|
import { join as join9 } from "path";
|
|
2323
2334
|
var MAX_IDS = 256;
|
|
2324
2335
|
function dir(log) {
|
|
@@ -2358,6 +2369,7 @@ function hasCompleted(workspaceId, eventId) {
|
|
|
2358
2369
|
}
|
|
2359
2370
|
function markCompleted(workspaceId, eventId) {
|
|
2360
2371
|
mark("completed", workspaceId, eventId);
|
|
2372
|
+
forgetTurnId(workspaceId, eventId);
|
|
2361
2373
|
}
|
|
2362
2374
|
var MAX_RESUME_ATTEMPTS = 3;
|
|
2363
2375
|
function resumeDir() {
|
|
@@ -2399,6 +2411,56 @@ function bumpResumeAttempt(workspaceId, eventId) {
|
|
|
2399
2411
|
);
|
|
2400
2412
|
return next;
|
|
2401
2413
|
}
|
|
2414
|
+
function turnDir() {
|
|
2415
|
+
return join9(cabaneDir(), "turns");
|
|
2416
|
+
}
|
|
2417
|
+
function turnPathFor(workspaceId) {
|
|
2418
|
+
return join9(turnDir(), encodeURIComponent(workspaceId));
|
|
2419
|
+
}
|
|
2420
|
+
var TURN_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
2421
|
+
function readTurnIds(workspaceId) {
|
|
2422
|
+
const out = /* @__PURE__ */ new Map();
|
|
2423
|
+
const path = turnPathFor(workspaceId);
|
|
2424
|
+
if (!existsSync7(path)) return out;
|
|
2425
|
+
try {
|
|
2426
|
+
for (const line of readFileSync5(path, "utf8").split("\n")) {
|
|
2427
|
+
const trimmed = line.trim();
|
|
2428
|
+
if (!trimmed) continue;
|
|
2429
|
+
const tab = trimmed.lastIndexOf(" ");
|
|
2430
|
+
if (tab <= 0) continue;
|
|
2431
|
+
const id = trimmed.slice(0, tab);
|
|
2432
|
+
const turnId = trimmed.slice(tab + 1);
|
|
2433
|
+
if (id && TURN_ID_RE.test(turnId)) out.set(id, turnId);
|
|
2434
|
+
}
|
|
2435
|
+
} catch {
|
|
2436
|
+
return out;
|
|
2437
|
+
}
|
|
2438
|
+
return out;
|
|
2439
|
+
}
|
|
2440
|
+
function turnIdForEvent(workspaceId, eventId) {
|
|
2441
|
+
return readTurnIds(workspaceId).get(eventId) ?? null;
|
|
2442
|
+
}
|
|
2443
|
+
var TURN_ID_OVERFLOW_WARN = 8192;
|
|
2444
|
+
function writeTurnIds(workspaceId, turns) {
|
|
2445
|
+
const entries = [...turns.entries()];
|
|
2446
|
+
mkdirSync7(turnDir(), { recursive: true });
|
|
2447
|
+
const path = turnPathFor(workspaceId);
|
|
2448
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
2449
|
+
writeFileSync5(tmp, entries.map(([id, t]) => `${id} ${t}`).join("\n") + "\n", "utf8");
|
|
2450
|
+
renameSync3(tmp, path);
|
|
2451
|
+
return entries.length;
|
|
2452
|
+
}
|
|
2453
|
+
function rememberTurnId(workspaceId, eventId, turnId) {
|
|
2454
|
+
const turns = readTurnIds(workspaceId);
|
|
2455
|
+
if (turns.get(eventId) === turnId) return turns.size;
|
|
2456
|
+
turns.set(eventId, turnId);
|
|
2457
|
+
return writeTurnIds(workspaceId, turns);
|
|
2458
|
+
}
|
|
2459
|
+
function forgetTurnId(workspaceId, eventId) {
|
|
2460
|
+
const turns = readTurnIds(workspaceId);
|
|
2461
|
+
if (!turns.delete(eventId)) return;
|
|
2462
|
+
writeTurnIds(workspaceId, turns);
|
|
2463
|
+
}
|
|
2402
2464
|
function noResume() {
|
|
2403
2465
|
return process.env.CABANE_COMPANION_NO_RESUME === "1";
|
|
2404
2466
|
}
|
|
@@ -7539,6 +7601,18 @@ function checkoutState(cwd) {
|
|
|
7539
7601
|
function runKey(conversationId, agentId) {
|
|
7540
7602
|
return `${conversationId}|${agentId}`;
|
|
7541
7603
|
}
|
|
7604
|
+
var LEASE_REFUSALS = /* @__PURE__ */ new Set([
|
|
7605
|
+
"dispatch_not_admitted",
|
|
7606
|
+
"turn_already_ended",
|
|
7607
|
+
"turn_belongs_elsewhere"
|
|
7608
|
+
]);
|
|
7609
|
+
function leaseRefusal(err) {
|
|
7610
|
+
if (!(err instanceof ApiError)) return null;
|
|
7611
|
+
const body = err.body;
|
|
7612
|
+
const code = typeof body === "object" && body !== null && typeof body.error === "string" ? body.error : null;
|
|
7613
|
+
if (code && LEASE_REFUSALS.has(code)) return code;
|
|
7614
|
+
return null;
|
|
7615
|
+
}
|
|
7542
7616
|
var ERROR_BODY_LOG_CAP = 2e3;
|
|
7543
7617
|
function describeErrorBody(body) {
|
|
7544
7618
|
if (body === void 0 || body === null) return void 0;
|
|
@@ -7573,6 +7647,16 @@ var Dispatcher = class {
|
|
|
7573
7647
|
}
|
|
7574
7648
|
opts;
|
|
7575
7649
|
// SJ383: per-(conversation, agent) abort registry.
|
|
7650
|
+
// CT1288: RunKey -> (turnId -> controller). This used to be one controller
|
|
7651
|
+
// per (conversation, agent), which quietly encoded the invariant the whole
|
|
7652
|
+
// task exists to enforce: that a pair can only ever have one live loop. When
|
|
7653
|
+
// that assumption broke, the second `set` EVICTED the first controller and
|
|
7654
|
+
// the first loop became permanently uncancellable — no other code path can
|
|
7655
|
+
// reach into a running turn. So the registry that Stop depends on failed
|
|
7656
|
+
// exactly when Stop was the thing you needed.
|
|
7657
|
+
//
|
|
7658
|
+
// Nesting by turn id costs nothing in the normal single-turn case and makes
|
|
7659
|
+
// `cancel` total: it aborts every loop under the pair, not the newest one.
|
|
7576
7660
|
aborts;
|
|
7577
7661
|
// CT1109: pairs already told, in the conversation, that their session state is
|
|
7578
7662
|
// being refused. The failure repeats every single turn until someone fixes the
|
|
@@ -7635,7 +7719,12 @@ var Dispatcher = class {
|
|
|
7635
7719
|
this.notifyEnd({ id: payload.messageId, ok: false, durationMs, reason });
|
|
7636
7720
|
return { ok: false, durationMs, reason };
|
|
7637
7721
|
}
|
|
7638
|
-
|
|
7722
|
+
// CT1288: `opts.turnId` is a RESUMED turn id — the id this event already ran
|
|
7723
|
+
// under before the companion died. Passing it makes the replay ask the
|
|
7724
|
+
// mailroom to re-admit the turn it already owns, which the server explicitly
|
|
7725
|
+
// supports ("a replay reports, it never re-points"). Omitted on a fresh
|
|
7726
|
+
// dispatch, where minting below is correct.
|
|
7727
|
+
async handle(payload, opts = {}) {
|
|
7639
7728
|
const startedAt = Date.now();
|
|
7640
7729
|
const dispatchId = payload.messageId;
|
|
7641
7730
|
const workspaceId = this.opts.workspaceId;
|
|
@@ -7645,7 +7734,7 @@ var Dispatcher = class {
|
|
|
7645
7734
|
agentId: payload.agentId,
|
|
7646
7735
|
messageId: payload.messageId
|
|
7647
7736
|
});
|
|
7648
|
-
const turnId = randomUUID();
|
|
7737
|
+
const turnId = opts.turnId ?? randomUUID();
|
|
7649
7738
|
let turnContext;
|
|
7650
7739
|
try {
|
|
7651
7740
|
turnContext = await this.opts.api.getTurnContext(
|
|
@@ -7867,7 +7956,18 @@ ${reason}`,
|
|
|
7867
7956
|
};
|
|
7868
7957
|
const key = runKey(payload.conversationId, payload.agentId);
|
|
7869
7958
|
const abortController = new AbortController();
|
|
7870
|
-
this.aborts.
|
|
7959
|
+
let pairAborts = this.aborts.get(key);
|
|
7960
|
+
if (!pairAborts) {
|
|
7961
|
+
pairAborts = /* @__PURE__ */ new Map();
|
|
7962
|
+
this.aborts.set(key, pairAborts);
|
|
7963
|
+
}
|
|
7964
|
+
pairAborts.set(turnId, abortController);
|
|
7965
|
+
const releaseAbort = () => {
|
|
7966
|
+
const pair = this.aborts.get(key);
|
|
7967
|
+
if (pair?.get(turnId) !== abortController) return;
|
|
7968
|
+
pair.delete(turnId);
|
|
7969
|
+
if (pair.size === 0) this.aborts.delete(key);
|
|
7970
|
+
};
|
|
7871
7971
|
let timeoutReason = null;
|
|
7872
7972
|
try {
|
|
7873
7973
|
await this.opts.api.setActiveRun(workspaceId, payload.conversationId, payload.agentId, {
|
|
@@ -7880,10 +7980,21 @@ ${reason}`,
|
|
|
7880
7980
|
turnId
|
|
7881
7981
|
});
|
|
7882
7982
|
} catch (err) {
|
|
7883
|
-
|
|
7884
|
-
|
|
7885
|
-
|
|
7983
|
+
const refusal = leaseRefusal(err);
|
|
7984
|
+
releaseAbort();
|
|
7985
|
+
if (refusal === "dispatch_not_admitted" || refusal === "turn_already_ended") {
|
|
7986
|
+
turnLog.error(
|
|
7987
|
+
{ refusal, turnId },
|
|
7988
|
+
"dispatcher: refused a turn lease; not running the model"
|
|
7989
|
+
);
|
|
7990
|
+
return this.concludeBeforeRun(payload, turnLog, startedAt, `lease_refused: ${refusal}`);
|
|
7991
|
+
}
|
|
7992
|
+
const reason = refusal ? `lease_refused: ${refusal}` : "lease_unconfirmed";
|
|
7993
|
+
turnLog.error(
|
|
7994
|
+
{ refusal, turnId, err: err instanceof Error ? err.message : String(err) },
|
|
7995
|
+
"dispatcher: turn lease not confirmed; not running the model"
|
|
7886
7996
|
);
|
|
7997
|
+
return this.concludeBeforeRun(payload, turnLog, startedAt, reason, reason);
|
|
7887
7998
|
}
|
|
7888
7999
|
const sendState = createSendState();
|
|
7889
8000
|
const skipState = createSkipState();
|
|
@@ -7987,6 +8098,7 @@ ${reason}`,
|
|
|
7987
8098
|
"dispatcher: runtime-unavailable notice post failed"
|
|
7988
8099
|
);
|
|
7989
8100
|
}
|
|
8101
|
+
releaseAbort();
|
|
7990
8102
|
return this.concludeBeforeRun(
|
|
7991
8103
|
payload,
|
|
7992
8104
|
turnLog,
|
|
@@ -8386,9 +8498,7 @@ ${reason}`,
|
|
|
8386
8498
|
"dispatcher: active-run clear failed terminally; server age-sweep is the backstop"
|
|
8387
8499
|
);
|
|
8388
8500
|
}
|
|
8389
|
-
|
|
8390
|
-
this.aborts.delete(key);
|
|
8391
|
-
}
|
|
8501
|
+
releaseAbort();
|
|
8392
8502
|
}
|
|
8393
8503
|
const durationMs = Date.now() - startedAt;
|
|
8394
8504
|
const finalReplyBody = committer.replyBody;
|
|
@@ -8432,11 +8542,13 @@ ${reason}`,
|
|
|
8432
8542
|
// THIS companion process. Returns true if an in-flight run was aborted.
|
|
8433
8543
|
cancel(conversationId, agentId) {
|
|
8434
8544
|
const key = runKey(conversationId, agentId);
|
|
8435
|
-
const
|
|
8436
|
-
if (!
|
|
8437
|
-
|
|
8438
|
-
|
|
8439
|
-
|
|
8545
|
+
const pair = this.aborts.get(key);
|
|
8546
|
+
if (!pair || pair.size === 0) return false;
|
|
8547
|
+
for (const ac of [...pair.values()]) {
|
|
8548
|
+
try {
|
|
8549
|
+
ac.abort();
|
|
8550
|
+
} catch {
|
|
8551
|
+
}
|
|
8440
8552
|
}
|
|
8441
8553
|
return true;
|
|
8442
8554
|
}
|
|
@@ -8500,7 +8612,7 @@ import {
|
|
|
8500
8612
|
mkdirSync as mkdirSync10,
|
|
8501
8613
|
readdirSync as readdirSync3,
|
|
8502
8614
|
readFileSync as readFileSync8,
|
|
8503
|
-
renameSync as
|
|
8615
|
+
renameSync as renameSync4,
|
|
8504
8616
|
rmSync as rmSync7,
|
|
8505
8617
|
writeFileSync as writeFileSync7
|
|
8506
8618
|
} from "fs";
|
|
@@ -8532,7 +8644,7 @@ var Outbox = class {
|
|
|
8532
8644
|
const tmp = `${target}.${process.pid}.tmp`;
|
|
8533
8645
|
try {
|
|
8534
8646
|
writeFileSync7(tmp, JSON.stringify(entry) + "\n", "utf8");
|
|
8535
|
-
|
|
8647
|
+
renameSync4(tmp, target);
|
|
8536
8648
|
} catch (err) {
|
|
8537
8649
|
try {
|
|
8538
8650
|
rmSync7(tmp, { force: true });
|
|
@@ -9333,6 +9445,19 @@ var CompanionSupervisor = class {
|
|
|
9333
9445
|
type: "device:dispatch_requested",
|
|
9334
9446
|
...wire.payload
|
|
9335
9447
|
};
|
|
9448
|
+
if (this.deviceId && payload.deviceId && payload.deviceId !== this.deviceId) {
|
|
9449
|
+
this.log.debug(
|
|
9450
|
+
{
|
|
9451
|
+
workspaceId: wr.workspaceId,
|
|
9452
|
+
conversationId: payload.conversationId,
|
|
9453
|
+
agentId: payload.agentId,
|
|
9454
|
+
addressedTo: payload.deviceId
|
|
9455
|
+
},
|
|
9456
|
+
"companion: dispatch addressed to another device; ignoring"
|
|
9457
|
+
);
|
|
9458
|
+
if (ev.id) wr.cursor.settle(ev.id);
|
|
9459
|
+
return;
|
|
9460
|
+
}
|
|
9336
9461
|
let agent = wr.agents.get(payload.agentId);
|
|
9337
9462
|
if (!agent) {
|
|
9338
9463
|
agent = await this.recoverRacedAgent(wr, payload);
|
|
@@ -9464,8 +9589,25 @@ var CompanionSupervisor = class {
|
|
|
9464
9589
|
"companion: re-dispatching interrupted turn (resume after restart)"
|
|
9465
9590
|
);
|
|
9466
9591
|
}
|
|
9467
|
-
|
|
9468
|
-
const
|
|
9592
|
+
const resumedTurnId = ev.id ? turnIdForEvent(workspaceId, ev.id) : null;
|
|
9593
|
+
const turnId = resumedTurnId ?? randomUUID2();
|
|
9594
|
+
if (ev.id) {
|
|
9595
|
+
const liveTurnIds = rememberTurnId(workspaceId, ev.id, turnId);
|
|
9596
|
+
if (liveTurnIds > TURN_ID_OVERFLOW_WARN) {
|
|
9597
|
+
this.log.error(
|
|
9598
|
+
{ workspaceId, liveTurnIds, threshold: TURN_ID_OVERFLOW_WARN },
|
|
9599
|
+
"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."
|
|
9600
|
+
);
|
|
9601
|
+
}
|
|
9602
|
+
markDispatched(workspaceId, ev.id);
|
|
9603
|
+
}
|
|
9604
|
+
if (resumedTurnId) {
|
|
9605
|
+
this.log.info(
|
|
9606
|
+
{ workspaceId, eventId: ev.id, turnId },
|
|
9607
|
+
"companion: resuming an interrupted turn under its original id"
|
|
9608
|
+
);
|
|
9609
|
+
}
|
|
9610
|
+
const result = await agent.dispatcher.handle(payload, { turnId });
|
|
9469
9611
|
if (ev.id) markCompleted(workspaceId, ev.id);
|
|
9470
9612
|
if (ev.id) wr.cursor.settle(ev.id);
|
|
9471
9613
|
const durationS = (result.durationMs / 1e3).toFixed(1);
|
|
@@ -9856,7 +9998,7 @@ async function createCompanionRuntime(opts = {}) {
|
|
|
9856
9998
|
opencodeServerUrl: cfg.opencode?.serverUrl,
|
|
9857
9999
|
codex: isCodexEnabled(cfg)
|
|
9858
10000
|
});
|
|
9859
|
-
const instanceId =
|
|
10001
|
+
const instanceId = randomUUID3();
|
|
9860
10002
|
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
9861
10003
|
const pre = readLiveRuntimeState();
|
|
9862
10004
|
if (pre && await verifyRuntime(pre) === "stale") clearRuntimeState();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cabane/companion",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.64",
|
|
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",
|