@cabane/companion 0.6.62 → 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 +222 -34
- package/dist/runtime.js +222 -34
- 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
|
}
|
|
@@ -7173,16 +7235,32 @@ var WAKE_ME_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${WAKE_ME_TOOL}`;
|
|
|
7173
7235
|
var CANCEL_WAKE_TOOL = "cancel_wake";
|
|
7174
7236
|
var CANCEL_WAKE_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${CANCEL_WAKE_TOOL}`;
|
|
7175
7237
|
function createReplyState() {
|
|
7176
|
-
return { answersMessageId: null };
|
|
7238
|
+
return { answersMessageId: null, order: null };
|
|
7177
7239
|
}
|
|
7178
7240
|
function createSendState() {
|
|
7179
|
-
return { agentId: null, message: null };
|
|
7241
|
+
return { agentId: null, message: null, order: null };
|
|
7242
|
+
}
|
|
7243
|
+
function createTurnControlOrder() {
|
|
7244
|
+
let calls = 0;
|
|
7245
|
+
return {
|
|
7246
|
+
next: () => {
|
|
7247
|
+
calls += 1;
|
|
7248
|
+
return calls;
|
|
7249
|
+
}
|
|
7250
|
+
};
|
|
7180
7251
|
}
|
|
7181
7252
|
function createSkipState() {
|
|
7182
7253
|
return { skipped: false, reason: null };
|
|
7183
7254
|
}
|
|
7184
7255
|
function createAskState() {
|
|
7185
|
-
return {
|
|
7256
|
+
return {
|
|
7257
|
+
targetUserId: null,
|
|
7258
|
+
question: null,
|
|
7259
|
+
headline: null,
|
|
7260
|
+
options: null,
|
|
7261
|
+
questions: null,
|
|
7262
|
+
order: null
|
|
7263
|
+
};
|
|
7186
7264
|
}
|
|
7187
7265
|
function createWakeState() {
|
|
7188
7266
|
return { afterSeconds: null, at: null, note: null, cancelled: false };
|
|
@@ -7212,7 +7290,7 @@ function wakeCommitField(state) {
|
|
|
7212
7290
|
}
|
|
7213
7291
|
};
|
|
7214
7292
|
}
|
|
7215
|
-
function createTurnControlMcpServer(sendState, skipState, askState, subAgentCreate, wakeState, replyState) {
|
|
7293
|
+
function createTurnControlMcpServer(sendState, skipState, askState, subAgentCreate, wakeState, replyState, controlOrder) {
|
|
7216
7294
|
return createSdkMcpServer({
|
|
7217
7295
|
name: COMPANION_LOCAL_MCP_SERVER,
|
|
7218
7296
|
version: "0.0.0",
|
|
@@ -7229,6 +7307,7 @@ function createTurnControlMcpServer(sendState, skipState, askState, subAgentCrea
|
|
|
7229
7307
|
async (args) => {
|
|
7230
7308
|
sendState.agentId = args.agentId;
|
|
7231
7309
|
sendState.message = args.message;
|
|
7310
|
+
sendState.order = controlOrder?.next() ?? null;
|
|
7232
7311
|
return {
|
|
7233
7312
|
content: [{ type: "text", text: JSON.stringify({ sent: args.agentId }) }]
|
|
7234
7313
|
};
|
|
@@ -7244,6 +7323,7 @@ function createTurnControlMcpServer(sendState, skipState, askState, subAgentCrea
|
|
|
7244
7323
|
},
|
|
7245
7324
|
async (args) => {
|
|
7246
7325
|
replyState.answersMessageId = args.messageId;
|
|
7326
|
+
replyState.order = controlOrder?.next() ?? null;
|
|
7247
7327
|
return {
|
|
7248
7328
|
content: [
|
|
7249
7329
|
{
|
|
@@ -7326,6 +7406,7 @@ function createTurnControlMcpServer(sendState, skipState, askState, subAgentCrea
|
|
|
7326
7406
|
};
|
|
7327
7407
|
}
|
|
7328
7408
|
askState.targetUserId = args.targetUserId;
|
|
7409
|
+
askState.order = controlOrder?.next() ?? null;
|
|
7329
7410
|
if (hasArray) {
|
|
7330
7411
|
askState.questions = args.questions;
|
|
7331
7412
|
askState.question = null;
|
|
@@ -7889,12 +7970,31 @@ var TurnCommitter = class {
|
|
|
7889
7970
|
// a wordless turn has no answer to bind, and the server's mute-settle
|
|
7890
7971
|
// notice speaks for it.
|
|
7891
7972
|
turnControlFields(kind) {
|
|
7892
|
-
|
|
7973
|
+
const fields = {
|
|
7893
7974
|
...this.answersField(kind),
|
|
7894
7975
|
...this.sendField(),
|
|
7895
7976
|
...this.askField(),
|
|
7896
7977
|
...this.wakeField()
|
|
7897
7978
|
};
|
|
7979
|
+
return { ...fields, ...this.orderField(fields) };
|
|
7980
|
+
}
|
|
7981
|
+
// CT1281: the order the control tools were CALLED, for the addressed rows the
|
|
7982
|
+
// server writes from this one commit. It reports a position only for an intent
|
|
7983
|
+
// that actually made it into the commit — an ask whose payload was incomplete,
|
|
7984
|
+
// or a self-send the committer stripped, contributes no row and so has no place
|
|
7985
|
+
// in the line. An intent with no recorded call (the runtime's auto-declared
|
|
7986
|
+
// reply) is deliberately absent: the server sorts it last, which is what it is.
|
|
7987
|
+
orderField(fields) {
|
|
7988
|
+
const order = {};
|
|
7989
|
+
const replyOrder = this.deps.replyState.order;
|
|
7990
|
+
if (fields.answersMessageId !== void 0 && replyOrder !== null) order.reply = replyOrder;
|
|
7991
|
+
if (fields.dispatch !== void 0 && this.deps.sendState.order !== null) {
|
|
7992
|
+
order.send = this.deps.sendState.order;
|
|
7993
|
+
}
|
|
7994
|
+
if (fields.ask !== void 0 && this.deps.askState.order !== null) {
|
|
7995
|
+
order.ask = this.deps.askState.order;
|
|
7996
|
+
}
|
|
7997
|
+
return Object.keys(order).length > 0 ? { turnControlOrder: order } : {};
|
|
7898
7998
|
}
|
|
7899
7999
|
// The declared reply: explicit `reply_to` first, else the runtime's own
|
|
7900
8000
|
// declaration for the turn that just answers — see
|
|
@@ -8002,6 +8102,18 @@ function checkoutState(cwd) {
|
|
|
8002
8102
|
function runKey(conversationId, agentId) {
|
|
8003
8103
|
return `${conversationId}|${agentId}`;
|
|
8004
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
|
+
}
|
|
8005
8117
|
var ERROR_BODY_LOG_CAP = 2e3;
|
|
8006
8118
|
function describeErrorBody(body) {
|
|
8007
8119
|
if (body === void 0 || body === null) return void 0;
|
|
@@ -8036,6 +8148,16 @@ var Dispatcher = class {
|
|
|
8036
8148
|
}
|
|
8037
8149
|
opts;
|
|
8038
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.
|
|
8039
8161
|
aborts;
|
|
8040
8162
|
// CT1109: pairs already told, in the conversation, that their session state is
|
|
8041
8163
|
// being refused. The failure repeats every single turn until someone fixes the
|
|
@@ -8098,7 +8220,12 @@ var Dispatcher = class {
|
|
|
8098
8220
|
this.notifyEnd({ id: payload.messageId, ok: false, durationMs, reason });
|
|
8099
8221
|
return { ok: false, durationMs, reason };
|
|
8100
8222
|
}
|
|
8101
|
-
|
|
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 = {}) {
|
|
8102
8229
|
const startedAt = Date.now();
|
|
8103
8230
|
const dispatchId = payload.messageId;
|
|
8104
8231
|
const workspaceId = this.opts.workspaceId;
|
|
@@ -8108,7 +8235,7 @@ var Dispatcher = class {
|
|
|
8108
8235
|
agentId: payload.agentId,
|
|
8109
8236
|
messageId: payload.messageId
|
|
8110
8237
|
});
|
|
8111
|
-
const turnId = randomUUID();
|
|
8238
|
+
const turnId = opts.turnId ?? randomUUID();
|
|
8112
8239
|
let turnContext;
|
|
8113
8240
|
try {
|
|
8114
8241
|
turnContext = await this.opts.api.getTurnContext(
|
|
@@ -8330,7 +8457,18 @@ ${reason}`,
|
|
|
8330
8457
|
};
|
|
8331
8458
|
const key = runKey(payload.conversationId, payload.agentId);
|
|
8332
8459
|
const abortController = new AbortController();
|
|
8333
|
-
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
|
+
};
|
|
8334
8472
|
let timeoutReason = null;
|
|
8335
8473
|
try {
|
|
8336
8474
|
await this.opts.api.setActiveRun(workspaceId, payload.conversationId, payload.agentId, {
|
|
@@ -8343,16 +8481,28 @@ ${reason}`,
|
|
|
8343
8481
|
turnId
|
|
8344
8482
|
});
|
|
8345
8483
|
} catch (err) {
|
|
8346
|
-
|
|
8347
|
-
|
|
8348
|
-
|
|
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"
|
|
8349
8497
|
);
|
|
8498
|
+
return this.concludeBeforeRun(payload, turnLog, startedAt, reason, reason);
|
|
8350
8499
|
}
|
|
8351
8500
|
const sendState = createSendState();
|
|
8352
8501
|
const skipState = createSkipState();
|
|
8353
8502
|
const askState = createAskState();
|
|
8354
8503
|
const wakeState = createWakeState();
|
|
8355
8504
|
const replyState = createReplyState();
|
|
8505
|
+
const controlOrder = createTurnControlOrder();
|
|
8356
8506
|
let spawnedSubAgent = false;
|
|
8357
8507
|
const subAgentCreate = async (args) => {
|
|
8358
8508
|
const dispatchTarget = args.agentId ?? payload.agentId;
|
|
@@ -8384,7 +8534,8 @@ ${reason}`,
|
|
|
8384
8534
|
askState,
|
|
8385
8535
|
subAgentCreate,
|
|
8386
8536
|
wakeState,
|
|
8387
|
-
replyState
|
|
8537
|
+
replyState,
|
|
8538
|
+
controlOrder
|
|
8388
8539
|
);
|
|
8389
8540
|
const request = buildCompanionTurnRequest({
|
|
8390
8541
|
turnContext,
|
|
@@ -8448,6 +8599,7 @@ ${reason}`,
|
|
|
8448
8599
|
"dispatcher: runtime-unavailable notice post failed"
|
|
8449
8600
|
);
|
|
8450
8601
|
}
|
|
8602
|
+
releaseAbort();
|
|
8451
8603
|
return this.concludeBeforeRun(
|
|
8452
8604
|
payload,
|
|
8453
8605
|
turnLog,
|
|
@@ -8503,11 +8655,12 @@ ${reason}`,
|
|
|
8503
8655
|
signal: abortController.signal,
|
|
8504
8656
|
log: turnLog,
|
|
8505
8657
|
nextSeq,
|
|
8506
|
-
// The committer reads this at commit to
|
|
8507
|
-
//
|
|
8658
|
+
// The committer reads this at commit to carry the addressed send on the
|
|
8659
|
+
// terminal row; the server writes the send itself as a distinct message.
|
|
8508
8660
|
sendState,
|
|
8509
|
-
// CT326: likewise the ask payload
|
|
8510
|
-
//
|
|
8661
|
+
// CT326: likewise the ask payload. CT1281: the server writes the ask as its
|
|
8662
|
+
// own addressed message to the human — which ENQUEUES like any other send —
|
|
8663
|
+
// and creates the `asks` row against that carrier, not against turn speech.
|
|
8511
8664
|
askState,
|
|
8512
8665
|
// CT442: likewise the wake payload — attached to the `final` row so the
|
|
8513
8666
|
// server arms the wake schedule atomically with the reply it rode on.
|
|
@@ -8532,6 +8685,7 @@ ${reason}`,
|
|
|
8532
8685
|
);
|
|
8533
8686
|
if (intent.ask) {
|
|
8534
8687
|
askState.targetUserId = intent.ask.targetUserId;
|
|
8688
|
+
askState.order = intent.askOrder ?? null;
|
|
8535
8689
|
if (intent.ask.questions && intent.ask.questions.length > 0) {
|
|
8536
8690
|
askState.questions = intent.ask.questions;
|
|
8537
8691
|
askState.question = null;
|
|
@@ -8560,8 +8714,12 @@ ${reason}`,
|
|
|
8560
8714
|
if (intent.sendAgentId && intent.sendBody) {
|
|
8561
8715
|
sendState.agentId = intent.sendAgentId;
|
|
8562
8716
|
sendState.message = intent.sendBody;
|
|
8717
|
+
sendState.order = intent.sendOrder ?? null;
|
|
8718
|
+
}
|
|
8719
|
+
if (intent.answersMessageId) {
|
|
8720
|
+
replyState.answersMessageId = intent.answersMessageId;
|
|
8721
|
+
replyState.order = intent.replyOrder ?? null;
|
|
8563
8722
|
}
|
|
8564
|
-
if (intent.answersMessageId) replyState.answersMessageId = intent.answersMessageId;
|
|
8565
8723
|
if (intent.skipped) {
|
|
8566
8724
|
skipState.skipped = true;
|
|
8567
8725
|
skipState.reason = intent.skipReason;
|
|
@@ -8841,9 +8999,7 @@ ${reason}`,
|
|
|
8841
8999
|
"dispatcher: active-run clear failed terminally; server age-sweep is the backstop"
|
|
8842
9000
|
);
|
|
8843
9001
|
}
|
|
8844
|
-
|
|
8845
|
-
this.aborts.delete(key);
|
|
8846
|
-
}
|
|
9002
|
+
releaseAbort();
|
|
8847
9003
|
}
|
|
8848
9004
|
const durationMs = Date.now() - startedAt;
|
|
8849
9005
|
const finalReplyBody = committer.replyBody;
|
|
@@ -8887,11 +9043,13 @@ ${reason}`,
|
|
|
8887
9043
|
// THIS companion process. Returns true if an in-flight run was aborted.
|
|
8888
9044
|
cancel(conversationId, agentId) {
|
|
8889
9045
|
const key = runKey(conversationId, agentId);
|
|
8890
|
-
const
|
|
8891
|
-
if (!
|
|
8892
|
-
|
|
8893
|
-
|
|
8894
|
-
|
|
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
|
+
}
|
|
8895
9053
|
}
|
|
8896
9054
|
return true;
|
|
8897
9055
|
}
|
|
@@ -8955,7 +9113,7 @@ import {
|
|
|
8955
9113
|
mkdirSync as mkdirSync10,
|
|
8956
9114
|
readdirSync as readdirSync3,
|
|
8957
9115
|
readFileSync as readFileSync8,
|
|
8958
|
-
renameSync as
|
|
9116
|
+
renameSync as renameSync4,
|
|
8959
9117
|
rmSync as rmSync7,
|
|
8960
9118
|
writeFileSync as writeFileSync7
|
|
8961
9119
|
} from "fs";
|
|
@@ -8987,7 +9145,7 @@ var Outbox = class {
|
|
|
8987
9145
|
const tmp = `${target}.${process.pid}.tmp`;
|
|
8988
9146
|
try {
|
|
8989
9147
|
writeFileSync7(tmp, JSON.stringify(entry) + "\n", "utf8");
|
|
8990
|
-
|
|
9148
|
+
renameSync4(tmp, target);
|
|
8991
9149
|
} catch (err) {
|
|
8992
9150
|
try {
|
|
8993
9151
|
rmSync7(tmp, { force: true });
|
|
@@ -9788,6 +9946,19 @@ var CompanionSupervisor = class {
|
|
|
9788
9946
|
type: "device:dispatch_requested",
|
|
9789
9947
|
...wire.payload
|
|
9790
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
|
+
}
|
|
9791
9962
|
let agent = wr.agents.get(payload.agentId);
|
|
9792
9963
|
if (!agent) {
|
|
9793
9964
|
agent = await this.recoverRacedAgent(wr, payload);
|
|
@@ -9919,8 +10090,25 @@ var CompanionSupervisor = class {
|
|
|
9919
10090
|
"companion: re-dispatching interrupted turn (resume after restart)"
|
|
9920
10091
|
);
|
|
9921
10092
|
}
|
|
9922
|
-
|
|
9923
|
-
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 });
|
|
9924
10112
|
if (ev.id) markCompleted(workspaceId, ev.id);
|
|
9925
10113
|
if (ev.id) wr.cursor.settle(ev.id);
|
|
9926
10114
|
const durationS = (result.durationMs / 1e3).toFixed(1);
|
|
@@ -10311,7 +10499,7 @@ async function createCompanionRuntime(opts = {}) {
|
|
|
10311
10499
|
opencodeServerUrl: cfg.opencode?.serverUrl,
|
|
10312
10500
|
codex: isCodexEnabled(cfg)
|
|
10313
10501
|
});
|
|
10314
|
-
const instanceId =
|
|
10502
|
+
const instanceId = randomUUID3();
|
|
10315
10503
|
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
10316
10504
|
const pre = readLiveRuntimeState();
|
|
10317
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
|
}
|
|
@@ -6672,16 +6734,32 @@ var WAKE_ME_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${WAKE_ME_TOOL}`;
|
|
|
6672
6734
|
var CANCEL_WAKE_TOOL = "cancel_wake";
|
|
6673
6735
|
var CANCEL_WAKE_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${CANCEL_WAKE_TOOL}`;
|
|
6674
6736
|
function createReplyState() {
|
|
6675
|
-
return { answersMessageId: null };
|
|
6737
|
+
return { answersMessageId: null, order: null };
|
|
6676
6738
|
}
|
|
6677
6739
|
function createSendState() {
|
|
6678
|
-
return { agentId: null, message: null };
|
|
6740
|
+
return { agentId: null, message: null, order: null };
|
|
6741
|
+
}
|
|
6742
|
+
function createTurnControlOrder() {
|
|
6743
|
+
let calls = 0;
|
|
6744
|
+
return {
|
|
6745
|
+
next: () => {
|
|
6746
|
+
calls += 1;
|
|
6747
|
+
return calls;
|
|
6748
|
+
}
|
|
6749
|
+
};
|
|
6679
6750
|
}
|
|
6680
6751
|
function createSkipState() {
|
|
6681
6752
|
return { skipped: false, reason: null };
|
|
6682
6753
|
}
|
|
6683
6754
|
function createAskState() {
|
|
6684
|
-
return {
|
|
6755
|
+
return {
|
|
6756
|
+
targetUserId: null,
|
|
6757
|
+
question: null,
|
|
6758
|
+
headline: null,
|
|
6759
|
+
options: null,
|
|
6760
|
+
questions: null,
|
|
6761
|
+
order: null
|
|
6762
|
+
};
|
|
6685
6763
|
}
|
|
6686
6764
|
function createWakeState() {
|
|
6687
6765
|
return { afterSeconds: null, at: null, note: null, cancelled: false };
|
|
@@ -6711,7 +6789,7 @@ function wakeCommitField(state) {
|
|
|
6711
6789
|
}
|
|
6712
6790
|
};
|
|
6713
6791
|
}
|
|
6714
|
-
function createTurnControlMcpServer(sendState, skipState, askState, subAgentCreate, wakeState, replyState) {
|
|
6792
|
+
function createTurnControlMcpServer(sendState, skipState, askState, subAgentCreate, wakeState, replyState, controlOrder) {
|
|
6715
6793
|
return createSdkMcpServer({
|
|
6716
6794
|
name: COMPANION_LOCAL_MCP_SERVER,
|
|
6717
6795
|
version: "0.0.0",
|
|
@@ -6728,6 +6806,7 @@ function createTurnControlMcpServer(sendState, skipState, askState, subAgentCrea
|
|
|
6728
6806
|
async (args) => {
|
|
6729
6807
|
sendState.agentId = args.agentId;
|
|
6730
6808
|
sendState.message = args.message;
|
|
6809
|
+
sendState.order = controlOrder?.next() ?? null;
|
|
6731
6810
|
return {
|
|
6732
6811
|
content: [{ type: "text", text: JSON.stringify({ sent: args.agentId }) }]
|
|
6733
6812
|
};
|
|
@@ -6743,6 +6822,7 @@ function createTurnControlMcpServer(sendState, skipState, askState, subAgentCrea
|
|
|
6743
6822
|
},
|
|
6744
6823
|
async (args) => {
|
|
6745
6824
|
replyState.answersMessageId = args.messageId;
|
|
6825
|
+
replyState.order = controlOrder?.next() ?? null;
|
|
6746
6826
|
return {
|
|
6747
6827
|
content: [
|
|
6748
6828
|
{
|
|
@@ -6825,6 +6905,7 @@ function createTurnControlMcpServer(sendState, skipState, askState, subAgentCrea
|
|
|
6825
6905
|
};
|
|
6826
6906
|
}
|
|
6827
6907
|
askState.targetUserId = args.targetUserId;
|
|
6908
|
+
askState.order = controlOrder?.next() ?? null;
|
|
6828
6909
|
if (hasArray) {
|
|
6829
6910
|
askState.questions = args.questions;
|
|
6830
6911
|
askState.question = null;
|
|
@@ -7388,12 +7469,31 @@ var TurnCommitter = class {
|
|
|
7388
7469
|
// a wordless turn has no answer to bind, and the server's mute-settle
|
|
7389
7470
|
// notice speaks for it.
|
|
7390
7471
|
turnControlFields(kind) {
|
|
7391
|
-
|
|
7472
|
+
const fields = {
|
|
7392
7473
|
...this.answersField(kind),
|
|
7393
7474
|
...this.sendField(),
|
|
7394
7475
|
...this.askField(),
|
|
7395
7476
|
...this.wakeField()
|
|
7396
7477
|
};
|
|
7478
|
+
return { ...fields, ...this.orderField(fields) };
|
|
7479
|
+
}
|
|
7480
|
+
// CT1281: the order the control tools were CALLED, for the addressed rows the
|
|
7481
|
+
// server writes from this one commit. It reports a position only for an intent
|
|
7482
|
+
// that actually made it into the commit — an ask whose payload was incomplete,
|
|
7483
|
+
// or a self-send the committer stripped, contributes no row and so has no place
|
|
7484
|
+
// in the line. An intent with no recorded call (the runtime's auto-declared
|
|
7485
|
+
// reply) is deliberately absent: the server sorts it last, which is what it is.
|
|
7486
|
+
orderField(fields) {
|
|
7487
|
+
const order = {};
|
|
7488
|
+
const replyOrder = this.deps.replyState.order;
|
|
7489
|
+
if (fields.answersMessageId !== void 0 && replyOrder !== null) order.reply = replyOrder;
|
|
7490
|
+
if (fields.dispatch !== void 0 && this.deps.sendState.order !== null) {
|
|
7491
|
+
order.send = this.deps.sendState.order;
|
|
7492
|
+
}
|
|
7493
|
+
if (fields.ask !== void 0 && this.deps.askState.order !== null) {
|
|
7494
|
+
order.ask = this.deps.askState.order;
|
|
7495
|
+
}
|
|
7496
|
+
return Object.keys(order).length > 0 ? { turnControlOrder: order } : {};
|
|
7397
7497
|
}
|
|
7398
7498
|
// The declared reply: explicit `reply_to` first, else the runtime's own
|
|
7399
7499
|
// declaration for the turn that just answers — see
|
|
@@ -7501,6 +7601,18 @@ function checkoutState(cwd) {
|
|
|
7501
7601
|
function runKey(conversationId, agentId) {
|
|
7502
7602
|
return `${conversationId}|${agentId}`;
|
|
7503
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
|
+
}
|
|
7504
7616
|
var ERROR_BODY_LOG_CAP = 2e3;
|
|
7505
7617
|
function describeErrorBody(body) {
|
|
7506
7618
|
if (body === void 0 || body === null) return void 0;
|
|
@@ -7535,6 +7647,16 @@ var Dispatcher = class {
|
|
|
7535
7647
|
}
|
|
7536
7648
|
opts;
|
|
7537
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.
|
|
7538
7660
|
aborts;
|
|
7539
7661
|
// CT1109: pairs already told, in the conversation, that their session state is
|
|
7540
7662
|
// being refused. The failure repeats every single turn until someone fixes the
|
|
@@ -7597,7 +7719,12 @@ var Dispatcher = class {
|
|
|
7597
7719
|
this.notifyEnd({ id: payload.messageId, ok: false, durationMs, reason });
|
|
7598
7720
|
return { ok: false, durationMs, reason };
|
|
7599
7721
|
}
|
|
7600
|
-
|
|
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 = {}) {
|
|
7601
7728
|
const startedAt = Date.now();
|
|
7602
7729
|
const dispatchId = payload.messageId;
|
|
7603
7730
|
const workspaceId = this.opts.workspaceId;
|
|
@@ -7607,7 +7734,7 @@ var Dispatcher = class {
|
|
|
7607
7734
|
agentId: payload.agentId,
|
|
7608
7735
|
messageId: payload.messageId
|
|
7609
7736
|
});
|
|
7610
|
-
const turnId = randomUUID();
|
|
7737
|
+
const turnId = opts.turnId ?? randomUUID();
|
|
7611
7738
|
let turnContext;
|
|
7612
7739
|
try {
|
|
7613
7740
|
turnContext = await this.opts.api.getTurnContext(
|
|
@@ -7829,7 +7956,18 @@ ${reason}`,
|
|
|
7829
7956
|
};
|
|
7830
7957
|
const key = runKey(payload.conversationId, payload.agentId);
|
|
7831
7958
|
const abortController = new AbortController();
|
|
7832
|
-
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
|
+
};
|
|
7833
7971
|
let timeoutReason = null;
|
|
7834
7972
|
try {
|
|
7835
7973
|
await this.opts.api.setActiveRun(workspaceId, payload.conversationId, payload.agentId, {
|
|
@@ -7842,16 +7980,28 @@ ${reason}`,
|
|
|
7842
7980
|
turnId
|
|
7843
7981
|
});
|
|
7844
7982
|
} catch (err) {
|
|
7845
|
-
|
|
7846
|
-
|
|
7847
|
-
|
|
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"
|
|
7848
7996
|
);
|
|
7997
|
+
return this.concludeBeforeRun(payload, turnLog, startedAt, reason, reason);
|
|
7849
7998
|
}
|
|
7850
7999
|
const sendState = createSendState();
|
|
7851
8000
|
const skipState = createSkipState();
|
|
7852
8001
|
const askState = createAskState();
|
|
7853
8002
|
const wakeState = createWakeState();
|
|
7854
8003
|
const replyState = createReplyState();
|
|
8004
|
+
const controlOrder = createTurnControlOrder();
|
|
7855
8005
|
let spawnedSubAgent = false;
|
|
7856
8006
|
const subAgentCreate = async (args) => {
|
|
7857
8007
|
const dispatchTarget = args.agentId ?? payload.agentId;
|
|
@@ -7883,7 +8033,8 @@ ${reason}`,
|
|
|
7883
8033
|
askState,
|
|
7884
8034
|
subAgentCreate,
|
|
7885
8035
|
wakeState,
|
|
7886
|
-
replyState
|
|
8036
|
+
replyState,
|
|
8037
|
+
controlOrder
|
|
7887
8038
|
);
|
|
7888
8039
|
const request = buildCompanionTurnRequest({
|
|
7889
8040
|
turnContext,
|
|
@@ -7947,6 +8098,7 @@ ${reason}`,
|
|
|
7947
8098
|
"dispatcher: runtime-unavailable notice post failed"
|
|
7948
8099
|
);
|
|
7949
8100
|
}
|
|
8101
|
+
releaseAbort();
|
|
7950
8102
|
return this.concludeBeforeRun(
|
|
7951
8103
|
payload,
|
|
7952
8104
|
turnLog,
|
|
@@ -8002,11 +8154,12 @@ ${reason}`,
|
|
|
8002
8154
|
signal: abortController.signal,
|
|
8003
8155
|
log: turnLog,
|
|
8004
8156
|
nextSeq,
|
|
8005
|
-
// The committer reads this at commit to
|
|
8006
|
-
//
|
|
8157
|
+
// The committer reads this at commit to carry the addressed send on the
|
|
8158
|
+
// terminal row; the server writes the send itself as a distinct message.
|
|
8007
8159
|
sendState,
|
|
8008
|
-
// CT326: likewise the ask payload
|
|
8009
|
-
//
|
|
8160
|
+
// CT326: likewise the ask payload. CT1281: the server writes the ask as its
|
|
8161
|
+
// own addressed message to the human — which ENQUEUES like any other send —
|
|
8162
|
+
// and creates the `asks` row against that carrier, not against turn speech.
|
|
8010
8163
|
askState,
|
|
8011
8164
|
// CT442: likewise the wake payload — attached to the `final` row so the
|
|
8012
8165
|
// server arms the wake schedule atomically with the reply it rode on.
|
|
@@ -8031,6 +8184,7 @@ ${reason}`,
|
|
|
8031
8184
|
);
|
|
8032
8185
|
if (intent.ask) {
|
|
8033
8186
|
askState.targetUserId = intent.ask.targetUserId;
|
|
8187
|
+
askState.order = intent.askOrder ?? null;
|
|
8034
8188
|
if (intent.ask.questions && intent.ask.questions.length > 0) {
|
|
8035
8189
|
askState.questions = intent.ask.questions;
|
|
8036
8190
|
askState.question = null;
|
|
@@ -8059,8 +8213,12 @@ ${reason}`,
|
|
|
8059
8213
|
if (intent.sendAgentId && intent.sendBody) {
|
|
8060
8214
|
sendState.agentId = intent.sendAgentId;
|
|
8061
8215
|
sendState.message = intent.sendBody;
|
|
8216
|
+
sendState.order = intent.sendOrder ?? null;
|
|
8217
|
+
}
|
|
8218
|
+
if (intent.answersMessageId) {
|
|
8219
|
+
replyState.answersMessageId = intent.answersMessageId;
|
|
8220
|
+
replyState.order = intent.replyOrder ?? null;
|
|
8062
8221
|
}
|
|
8063
|
-
if (intent.answersMessageId) replyState.answersMessageId = intent.answersMessageId;
|
|
8064
8222
|
if (intent.skipped) {
|
|
8065
8223
|
skipState.skipped = true;
|
|
8066
8224
|
skipState.reason = intent.skipReason;
|
|
@@ -8340,9 +8498,7 @@ ${reason}`,
|
|
|
8340
8498
|
"dispatcher: active-run clear failed terminally; server age-sweep is the backstop"
|
|
8341
8499
|
);
|
|
8342
8500
|
}
|
|
8343
|
-
|
|
8344
|
-
this.aborts.delete(key);
|
|
8345
|
-
}
|
|
8501
|
+
releaseAbort();
|
|
8346
8502
|
}
|
|
8347
8503
|
const durationMs = Date.now() - startedAt;
|
|
8348
8504
|
const finalReplyBody = committer.replyBody;
|
|
@@ -8386,11 +8542,13 @@ ${reason}`,
|
|
|
8386
8542
|
// THIS companion process. Returns true if an in-flight run was aborted.
|
|
8387
8543
|
cancel(conversationId, agentId) {
|
|
8388
8544
|
const key = runKey(conversationId, agentId);
|
|
8389
|
-
const
|
|
8390
|
-
if (!
|
|
8391
|
-
|
|
8392
|
-
|
|
8393
|
-
|
|
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
|
+
}
|
|
8394
8552
|
}
|
|
8395
8553
|
return true;
|
|
8396
8554
|
}
|
|
@@ -8454,7 +8612,7 @@ import {
|
|
|
8454
8612
|
mkdirSync as mkdirSync10,
|
|
8455
8613
|
readdirSync as readdirSync3,
|
|
8456
8614
|
readFileSync as readFileSync8,
|
|
8457
|
-
renameSync as
|
|
8615
|
+
renameSync as renameSync4,
|
|
8458
8616
|
rmSync as rmSync7,
|
|
8459
8617
|
writeFileSync as writeFileSync7
|
|
8460
8618
|
} from "fs";
|
|
@@ -8486,7 +8644,7 @@ var Outbox = class {
|
|
|
8486
8644
|
const tmp = `${target}.${process.pid}.tmp`;
|
|
8487
8645
|
try {
|
|
8488
8646
|
writeFileSync7(tmp, JSON.stringify(entry) + "\n", "utf8");
|
|
8489
|
-
|
|
8647
|
+
renameSync4(tmp, target);
|
|
8490
8648
|
} catch (err) {
|
|
8491
8649
|
try {
|
|
8492
8650
|
rmSync7(tmp, { force: true });
|
|
@@ -9287,6 +9445,19 @@ var CompanionSupervisor = class {
|
|
|
9287
9445
|
type: "device:dispatch_requested",
|
|
9288
9446
|
...wire.payload
|
|
9289
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
|
+
}
|
|
9290
9461
|
let agent = wr.agents.get(payload.agentId);
|
|
9291
9462
|
if (!agent) {
|
|
9292
9463
|
agent = await this.recoverRacedAgent(wr, payload);
|
|
@@ -9418,8 +9589,25 @@ var CompanionSupervisor = class {
|
|
|
9418
9589
|
"companion: re-dispatching interrupted turn (resume after restart)"
|
|
9419
9590
|
);
|
|
9420
9591
|
}
|
|
9421
|
-
|
|
9422
|
-
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 });
|
|
9423
9611
|
if (ev.id) markCompleted(workspaceId, ev.id);
|
|
9424
9612
|
if (ev.id) wr.cursor.settle(ev.id);
|
|
9425
9613
|
const durationS = (result.durationMs / 1e3).toFixed(1);
|
|
@@ -9810,7 +9998,7 @@ async function createCompanionRuntime(opts = {}) {
|
|
|
9810
9998
|
opencodeServerUrl: cfg.opencode?.serverUrl,
|
|
9811
9999
|
codex: isCodexEnabled(cfg)
|
|
9812
10000
|
});
|
|
9813
|
-
const instanceId =
|
|
10001
|
+
const instanceId = randomUUID3();
|
|
9814
10002
|
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
9815
10003
|
const pre = readLiveRuntimeState();
|
|
9816
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",
|