@cabane/companion 0.6.63 → 0.6.65
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 +182 -55
- package/dist/runtime.js +182 -55
- 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
|
|
@@ -2586,15 +2597,16 @@ var CabaneApi = class {
|
|
|
2586
2597
|
// (transport errors + 5xx), never a 4xx, so no `(turnId, kind)` row is
|
|
2587
2598
|
// double-posted. The caller threads its turn abort signal so a cancel
|
|
2588
2599
|
// mid-commit aborts the in-flight POST rather than letting it land.
|
|
2589
|
-
// CT11: `kind`
|
|
2590
|
-
//
|
|
2591
|
-
//
|
|
2592
|
-
//
|
|
2593
|
-
//
|
|
2594
|
-
//
|
|
2595
|
-
//
|
|
2596
|
-
//
|
|
2597
|
-
//
|
|
2600
|
+
// CT11: `kind` includes `'stopped'` for the terminal cancel marker — same
|
|
2601
|
+
// wire shape as `progress`/`final`, distinguished only by `kind` so the chat
|
|
2602
|
+
// drawer's turn-group renderer treats it as a closing row. CT1295: this
|
|
2603
|
+
// companion no longer WRITES that kind — the server does, in the transaction
|
|
2604
|
+
// that terminalizes the stopped turn — so the value survives here only as
|
|
2605
|
+
// wire vocabulary the server also speaks. `seq` is the companion's per-turn
|
|
2606
|
+
// monotonic counter, stamped on the row so the merged timeline orders the
|
|
2607
|
+
// commit deterministically against the persisted tool/thinking rows. Both
|
|
2608
|
+
// fields are optional on the wire — an older companion that didn't mint seq
|
|
2609
|
+
// still validates (the server defaults to 0).
|
|
2598
2610
|
postTurnMessage(workspaceId, conversationId, body, signal) {
|
|
2599
2611
|
return this.durableCommit(
|
|
2600
2612
|
"message",
|
|
@@ -2819,7 +2831,7 @@ var CursorTracker = class {
|
|
|
2819
2831
|
};
|
|
2820
2832
|
|
|
2821
2833
|
// src/dispatch-dedupe.ts
|
|
2822
|
-
import { mkdirSync as mkdirSync7, readFileSync as readFileSync5, writeFileSync as writeFileSync5, existsSync as existsSync7 } from "fs";
|
|
2834
|
+
import { mkdirSync as mkdirSync7, readFileSync as readFileSync5, writeFileSync as writeFileSync5, renameSync as renameSync3, existsSync as existsSync7 } from "fs";
|
|
2823
2835
|
import { join as join9 } from "path";
|
|
2824
2836
|
var MAX_IDS = 256;
|
|
2825
2837
|
function dir(log) {
|
|
@@ -2859,6 +2871,7 @@ function hasCompleted(workspaceId, eventId) {
|
|
|
2859
2871
|
}
|
|
2860
2872
|
function markCompleted(workspaceId, eventId) {
|
|
2861
2873
|
mark("completed", workspaceId, eventId);
|
|
2874
|
+
forgetTurnId(workspaceId, eventId);
|
|
2862
2875
|
}
|
|
2863
2876
|
var MAX_RESUME_ATTEMPTS = 3;
|
|
2864
2877
|
function resumeDir() {
|
|
@@ -2900,6 +2913,56 @@ function bumpResumeAttempt(workspaceId, eventId) {
|
|
|
2900
2913
|
);
|
|
2901
2914
|
return next;
|
|
2902
2915
|
}
|
|
2916
|
+
function turnDir() {
|
|
2917
|
+
return join9(cabaneDir(), "turns");
|
|
2918
|
+
}
|
|
2919
|
+
function turnPathFor(workspaceId) {
|
|
2920
|
+
return join9(turnDir(), encodeURIComponent(workspaceId));
|
|
2921
|
+
}
|
|
2922
|
+
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;
|
|
2923
|
+
function readTurnIds(workspaceId) {
|
|
2924
|
+
const out = /* @__PURE__ */ new Map();
|
|
2925
|
+
const path = turnPathFor(workspaceId);
|
|
2926
|
+
if (!existsSync7(path)) return out;
|
|
2927
|
+
try {
|
|
2928
|
+
for (const line of readFileSync5(path, "utf8").split("\n")) {
|
|
2929
|
+
const trimmed = line.trim();
|
|
2930
|
+
if (!trimmed) continue;
|
|
2931
|
+
const tab = trimmed.lastIndexOf(" ");
|
|
2932
|
+
if (tab <= 0) continue;
|
|
2933
|
+
const id = trimmed.slice(0, tab);
|
|
2934
|
+
const turnId = trimmed.slice(tab + 1);
|
|
2935
|
+
if (id && TURN_ID_RE.test(turnId)) out.set(id, turnId);
|
|
2936
|
+
}
|
|
2937
|
+
} catch {
|
|
2938
|
+
return out;
|
|
2939
|
+
}
|
|
2940
|
+
return out;
|
|
2941
|
+
}
|
|
2942
|
+
function turnIdForEvent(workspaceId, eventId) {
|
|
2943
|
+
return readTurnIds(workspaceId).get(eventId) ?? null;
|
|
2944
|
+
}
|
|
2945
|
+
var TURN_ID_OVERFLOW_WARN = 8192;
|
|
2946
|
+
function writeTurnIds(workspaceId, turns) {
|
|
2947
|
+
const entries = [...turns.entries()];
|
|
2948
|
+
mkdirSync7(turnDir(), { recursive: true });
|
|
2949
|
+
const path = turnPathFor(workspaceId);
|
|
2950
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
2951
|
+
writeFileSync5(tmp, entries.map(([id, t]) => `${id} ${t}`).join("\n") + "\n", "utf8");
|
|
2952
|
+
renameSync3(tmp, path);
|
|
2953
|
+
return entries.length;
|
|
2954
|
+
}
|
|
2955
|
+
function rememberTurnId(workspaceId, eventId, turnId) {
|
|
2956
|
+
const turns = readTurnIds(workspaceId);
|
|
2957
|
+
if (turns.get(eventId) === turnId) return turns.size;
|
|
2958
|
+
turns.set(eventId, turnId);
|
|
2959
|
+
return writeTurnIds(workspaceId, turns);
|
|
2960
|
+
}
|
|
2961
|
+
function forgetTurnId(workspaceId, eventId) {
|
|
2962
|
+
const turns = readTurnIds(workspaceId);
|
|
2963
|
+
if (!turns.delete(eventId)) return;
|
|
2964
|
+
writeTurnIds(workspaceId, turns);
|
|
2965
|
+
}
|
|
2903
2966
|
function noResume() {
|
|
2904
2967
|
return process.env.CABANE_COMPANION_NO_RESUME === "1";
|
|
2905
2968
|
}
|
|
@@ -4317,7 +4380,7 @@ var BASE_CLAUDE_CODE_CONFORMANCE_FIXTURES = [
|
|
|
4317
4380
|
{
|
|
4318
4381
|
// HIGHEST-RISK: cancel mid-stream. The abort lands after the tool result;
|
|
4319
4382
|
// the adapter stops before the closing reply — no final text, no `result`
|
|
4320
|
-
// event.
|
|
4383
|
+
// event. CT1295: the server writes the `kind:'stopped'` marker.
|
|
4321
4384
|
name: "cancel mid-stream",
|
|
4322
4385
|
request: makeRequest(),
|
|
4323
4386
|
nativeStream: [
|
|
@@ -5438,7 +5501,7 @@ var OPENCODE_CONFORMANCE_FIXTURES = [
|
|
|
5438
5501
|
{
|
|
5439
5502
|
// HIGHEST-RISK: cancel mid-stream. The abort lands after the tool result; the
|
|
5440
5503
|
// adapter stops before the closing reply — no final text, no `result` event.
|
|
5441
|
-
//
|
|
5504
|
+
// CT1295: the server writes the `kind:'stopped'` marker.
|
|
5442
5505
|
name: "cancel mid-stream",
|
|
5443
5506
|
request: makeRequest2(),
|
|
5444
5507
|
nativeStream: [
|
|
@@ -6730,7 +6793,7 @@ var CODEX_CONFORMANCE_FIXTURES = [
|
|
|
6730
6793
|
{
|
|
6731
6794
|
// HIGHEST-RISK: cancel mid-stream. The abort lands after the tool result; the
|
|
6732
6795
|
// adapter stops before the closing reply — no final text, no `result` event.
|
|
6733
|
-
//
|
|
6796
|
+
// CT1295: the server writes the `kind:'stopped'` marker.
|
|
6734
6797
|
name: "cancel mid-stream",
|
|
6735
6798
|
request: makeRequest3(),
|
|
6736
6799
|
nativeStream: [
|
|
@@ -7886,8 +7949,10 @@ var TurnCommitter = class {
|
|
|
7886
7949
|
// End-of-turn progress promotion. The held-text flush is now the adapter's
|
|
7887
7950
|
// job (it emits the closing reply as a `text` event before `result`), so this
|
|
7888
7951
|
// only asks the pump to promote agent-authored interim text. A wordless turn
|
|
7889
|
-
// stays wordless and the dispatcher closes it with a
|
|
7890
|
-
//
|
|
7952
|
+
// stays wordless and the dispatcher closes it with a `silent`/`skipped`
|
|
7953
|
+
// marker (CT1295: a CANCELLED turn is closed by the SERVER's `stopped`
|
|
7954
|
+
// marker, not from this side). Guards on the abort signal, so on cancel no
|
|
7955
|
+
// `final` is forced.
|
|
7891
7956
|
async finalize(okResult) {
|
|
7892
7957
|
await this.pump.finalize(okResult);
|
|
7893
7958
|
}
|
|
@@ -8005,7 +8070,6 @@ var TurnCommitter = class {
|
|
|
8005
8070
|
var PREPARING_TOOL_NAME = "preparing";
|
|
8006
8071
|
var PREPARE_FAILED_PREFIX = "**Couldn't prepare your environment.** I wasn't able to provision a working directory for this conversation, so I can't run this turn. The provisioning command reported:";
|
|
8007
8072
|
var MISSING_SECRET_PREFIX = "**Missing secret on this companion.** This agent's tools need a credential this device hasn't been given, so I can't run this turn safely. Declare it in this companion\u2019s secret store (`~/.cabane/secrets.json`) and try again. Missing:";
|
|
8008
|
-
var STOPPED_MARKER_BODY = "(stopped)";
|
|
8009
8073
|
var RUNTIME_UNAVAILABLE_PREFIX = "**This agent's runtime isn't available on this companion.** The model this agent uses needs a runtime this device isn't running, so I can't run this turn here. Details:";
|
|
8010
8074
|
var UNEXPECTED_ROLE_REASON = `refused the wake trigger (role "system" isn't dispatchable on this companion) \u2014 the companion is likely running outdated code; refresh it, then re-address the agent`;
|
|
8011
8075
|
var SKIPPED_MARKER_BODY = "(skipped)";
|
|
@@ -8040,6 +8104,18 @@ function checkoutState(cwd) {
|
|
|
8040
8104
|
function runKey(conversationId, agentId) {
|
|
8041
8105
|
return `${conversationId}|${agentId}`;
|
|
8042
8106
|
}
|
|
8107
|
+
var LEASE_REFUSALS = /* @__PURE__ */ new Set([
|
|
8108
|
+
"dispatch_not_admitted",
|
|
8109
|
+
"turn_already_ended",
|
|
8110
|
+
"turn_belongs_elsewhere"
|
|
8111
|
+
]);
|
|
8112
|
+
function leaseRefusal(err) {
|
|
8113
|
+
if (!(err instanceof ApiError)) return null;
|
|
8114
|
+
const body = err.body;
|
|
8115
|
+
const code = typeof body === "object" && body !== null && typeof body.error === "string" ? body.error : null;
|
|
8116
|
+
if (code && LEASE_REFUSALS.has(code)) return code;
|
|
8117
|
+
return null;
|
|
8118
|
+
}
|
|
8043
8119
|
var ERROR_BODY_LOG_CAP = 2e3;
|
|
8044
8120
|
function describeErrorBody(body) {
|
|
8045
8121
|
if (body === void 0 || body === null) return void 0;
|
|
@@ -8074,6 +8150,16 @@ var Dispatcher = class {
|
|
|
8074
8150
|
}
|
|
8075
8151
|
opts;
|
|
8076
8152
|
// SJ383: per-(conversation, agent) abort registry.
|
|
8153
|
+
// CT1288: RunKey -> (turnId -> controller). This used to be one controller
|
|
8154
|
+
// per (conversation, agent), which quietly encoded the invariant the whole
|
|
8155
|
+
// task exists to enforce: that a pair can only ever have one live loop. When
|
|
8156
|
+
// that assumption broke, the second `set` EVICTED the first controller and
|
|
8157
|
+
// the first loop became permanently uncancellable — no other code path can
|
|
8158
|
+
// reach into a running turn. So the registry that Stop depends on failed
|
|
8159
|
+
// exactly when Stop was the thing you needed.
|
|
8160
|
+
//
|
|
8161
|
+
// Nesting by turn id costs nothing in the normal single-turn case and makes
|
|
8162
|
+
// `cancel` total: it aborts every loop under the pair, not the newest one.
|
|
8077
8163
|
aborts;
|
|
8078
8164
|
// CT1109: pairs already told, in the conversation, that their session state is
|
|
8079
8165
|
// being refused. The failure repeats every single turn until someone fixes the
|
|
@@ -8136,7 +8222,12 @@ var Dispatcher = class {
|
|
|
8136
8222
|
this.notifyEnd({ id: payload.messageId, ok: false, durationMs, reason });
|
|
8137
8223
|
return { ok: false, durationMs, reason };
|
|
8138
8224
|
}
|
|
8139
|
-
|
|
8225
|
+
// CT1288: `opts.turnId` is a RESUMED turn id — the id this event already ran
|
|
8226
|
+
// under before the companion died. Passing it makes the replay ask the
|
|
8227
|
+
// mailroom to re-admit the turn it already owns, which the server explicitly
|
|
8228
|
+
// supports ("a replay reports, it never re-points"). Omitted on a fresh
|
|
8229
|
+
// dispatch, where minting below is correct.
|
|
8230
|
+
async handle(payload, opts = {}) {
|
|
8140
8231
|
const startedAt = Date.now();
|
|
8141
8232
|
const dispatchId = payload.messageId;
|
|
8142
8233
|
const workspaceId = this.opts.workspaceId;
|
|
@@ -8146,7 +8237,7 @@ var Dispatcher = class {
|
|
|
8146
8237
|
agentId: payload.agentId,
|
|
8147
8238
|
messageId: payload.messageId
|
|
8148
8239
|
});
|
|
8149
|
-
const turnId = randomUUID();
|
|
8240
|
+
const turnId = opts.turnId ?? randomUUID();
|
|
8150
8241
|
let turnContext;
|
|
8151
8242
|
try {
|
|
8152
8243
|
turnContext = await this.opts.api.getTurnContext(
|
|
@@ -8368,7 +8459,18 @@ ${reason}`,
|
|
|
8368
8459
|
};
|
|
8369
8460
|
const key = runKey(payload.conversationId, payload.agentId);
|
|
8370
8461
|
const abortController = new AbortController();
|
|
8371
|
-
this.aborts.
|
|
8462
|
+
let pairAborts = this.aborts.get(key);
|
|
8463
|
+
if (!pairAborts) {
|
|
8464
|
+
pairAborts = /* @__PURE__ */ new Map();
|
|
8465
|
+
this.aborts.set(key, pairAborts);
|
|
8466
|
+
}
|
|
8467
|
+
pairAborts.set(turnId, abortController);
|
|
8468
|
+
const releaseAbort = () => {
|
|
8469
|
+
const pair2 = this.aborts.get(key);
|
|
8470
|
+
if (pair2?.get(turnId) !== abortController) return;
|
|
8471
|
+
pair2.delete(turnId);
|
|
8472
|
+
if (pair2.size === 0) this.aborts.delete(key);
|
|
8473
|
+
};
|
|
8372
8474
|
let timeoutReason = null;
|
|
8373
8475
|
try {
|
|
8374
8476
|
await this.opts.api.setActiveRun(workspaceId, payload.conversationId, payload.agentId, {
|
|
@@ -8381,10 +8483,21 @@ ${reason}`,
|
|
|
8381
8483
|
turnId
|
|
8382
8484
|
});
|
|
8383
8485
|
} catch (err) {
|
|
8384
|
-
|
|
8385
|
-
|
|
8386
|
-
|
|
8486
|
+
const refusal = leaseRefusal(err);
|
|
8487
|
+
releaseAbort();
|
|
8488
|
+
if (refusal === "dispatch_not_admitted" || refusal === "turn_already_ended") {
|
|
8489
|
+
turnLog.error(
|
|
8490
|
+
{ refusal, turnId },
|
|
8491
|
+
"dispatcher: refused a turn lease; not running the model"
|
|
8492
|
+
);
|
|
8493
|
+
return this.concludeBeforeRun(payload, turnLog, startedAt, `lease_refused: ${refusal}`);
|
|
8494
|
+
}
|
|
8495
|
+
const reason = refusal ? `lease_refused: ${refusal}` : "lease_unconfirmed";
|
|
8496
|
+
turnLog.error(
|
|
8497
|
+
{ refusal, turnId, err: err instanceof Error ? err.message : String(err) },
|
|
8498
|
+
"dispatcher: turn lease not confirmed; not running the model"
|
|
8387
8499
|
);
|
|
8500
|
+
return this.concludeBeforeRun(payload, turnLog, startedAt, reason, reason);
|
|
8388
8501
|
}
|
|
8389
8502
|
const sendState = createSendState();
|
|
8390
8503
|
const skipState = createSkipState();
|
|
@@ -8488,6 +8601,7 @@ ${reason}`,
|
|
|
8488
8601
|
"dispatcher: runtime-unavailable notice post failed"
|
|
8489
8602
|
);
|
|
8490
8603
|
}
|
|
8604
|
+
releaseAbort();
|
|
8491
8605
|
return this.concludeBeforeRun(
|
|
8492
8606
|
payload,
|
|
8493
8607
|
turnLog,
|
|
@@ -8788,23 +8902,6 @@ ${reason}`,
|
|
|
8788
8902
|
}
|
|
8789
8903
|
}
|
|
8790
8904
|
}
|
|
8791
|
-
if (userCancelled) {
|
|
8792
|
-
try {
|
|
8793
|
-
await this.opts.api.postTurnMessage(workspaceId, payload.conversationId, {
|
|
8794
|
-
body: STOPPED_MARKER_BODY,
|
|
8795
|
-
kind: "stopped",
|
|
8796
|
-
turnId,
|
|
8797
|
-
seq: nextSeq(),
|
|
8798
|
-
// CT113: the stopped marker is still "about" the triggering message.
|
|
8799
|
-
parentMessageId: payload.messageId
|
|
8800
|
-
});
|
|
8801
|
-
} catch (err) {
|
|
8802
|
-
turnLog.warn(
|
|
8803
|
-
{ err: err instanceof Error ? err.message : String(err) },
|
|
8804
|
-
"dispatcher: stopped-marker commit failed"
|
|
8805
|
-
);
|
|
8806
|
-
}
|
|
8807
|
-
}
|
|
8808
8905
|
const body = {
|
|
8809
8906
|
activeRunStartedAt: null,
|
|
8810
8907
|
// CT277: the server keys the turn-end metadata UPDATE (ended_at + tokens)
|
|
@@ -8887,9 +8984,7 @@ ${reason}`,
|
|
|
8887
8984
|
"dispatcher: active-run clear failed terminally; server age-sweep is the backstop"
|
|
8888
8985
|
);
|
|
8889
8986
|
}
|
|
8890
|
-
|
|
8891
|
-
this.aborts.delete(key);
|
|
8892
|
-
}
|
|
8987
|
+
releaseAbort();
|
|
8893
8988
|
}
|
|
8894
8989
|
const durationMs = Date.now() - startedAt;
|
|
8895
8990
|
const finalReplyBody = committer.replyBody;
|
|
@@ -8933,11 +9028,13 @@ ${reason}`,
|
|
|
8933
9028
|
// THIS companion process. Returns true if an in-flight run was aborted.
|
|
8934
9029
|
cancel(conversationId, agentId) {
|
|
8935
9030
|
const key = runKey(conversationId, agentId);
|
|
8936
|
-
const
|
|
8937
|
-
if (!
|
|
8938
|
-
|
|
8939
|
-
|
|
8940
|
-
|
|
9031
|
+
const pair2 = this.aborts.get(key);
|
|
9032
|
+
if (!pair2 || pair2.size === 0) return false;
|
|
9033
|
+
for (const ac of [...pair2.values()]) {
|
|
9034
|
+
try {
|
|
9035
|
+
ac.abort();
|
|
9036
|
+
} catch {
|
|
9037
|
+
}
|
|
8941
9038
|
}
|
|
8942
9039
|
return true;
|
|
8943
9040
|
}
|
|
@@ -9001,7 +9098,7 @@ import {
|
|
|
9001
9098
|
mkdirSync as mkdirSync10,
|
|
9002
9099
|
readdirSync as readdirSync3,
|
|
9003
9100
|
readFileSync as readFileSync8,
|
|
9004
|
-
renameSync as
|
|
9101
|
+
renameSync as renameSync4,
|
|
9005
9102
|
rmSync as rmSync7,
|
|
9006
9103
|
writeFileSync as writeFileSync7
|
|
9007
9104
|
} from "fs";
|
|
@@ -9033,7 +9130,7 @@ var Outbox = class {
|
|
|
9033
9130
|
const tmp = `${target}.${process.pid}.tmp`;
|
|
9034
9131
|
try {
|
|
9035
9132
|
writeFileSync7(tmp, JSON.stringify(entry) + "\n", "utf8");
|
|
9036
|
-
|
|
9133
|
+
renameSync4(tmp, target);
|
|
9037
9134
|
} catch (err) {
|
|
9038
9135
|
try {
|
|
9039
9136
|
rmSync7(tmp, { force: true });
|
|
@@ -9834,6 +9931,19 @@ var CompanionSupervisor = class {
|
|
|
9834
9931
|
type: "device:dispatch_requested",
|
|
9835
9932
|
...wire.payload
|
|
9836
9933
|
};
|
|
9934
|
+
if (this.deviceId && payload.deviceId && payload.deviceId !== this.deviceId) {
|
|
9935
|
+
this.log.debug(
|
|
9936
|
+
{
|
|
9937
|
+
workspaceId: wr.workspaceId,
|
|
9938
|
+
conversationId: payload.conversationId,
|
|
9939
|
+
agentId: payload.agentId,
|
|
9940
|
+
addressedTo: payload.deviceId
|
|
9941
|
+
},
|
|
9942
|
+
"companion: dispatch addressed to another device; ignoring"
|
|
9943
|
+
);
|
|
9944
|
+
if (ev.id) wr.cursor.settle(ev.id);
|
|
9945
|
+
return;
|
|
9946
|
+
}
|
|
9837
9947
|
let agent = wr.agents.get(payload.agentId);
|
|
9838
9948
|
if (!agent) {
|
|
9839
9949
|
agent = await this.recoverRacedAgent(wr, payload);
|
|
@@ -9965,8 +10075,25 @@ var CompanionSupervisor = class {
|
|
|
9965
10075
|
"companion: re-dispatching interrupted turn (resume after restart)"
|
|
9966
10076
|
);
|
|
9967
10077
|
}
|
|
9968
|
-
|
|
9969
|
-
const
|
|
10078
|
+
const resumedTurnId = ev.id ? turnIdForEvent(workspaceId, ev.id) : null;
|
|
10079
|
+
const turnId = resumedTurnId ?? randomUUID2();
|
|
10080
|
+
if (ev.id) {
|
|
10081
|
+
const liveTurnIds = rememberTurnId(workspaceId, ev.id, turnId);
|
|
10082
|
+
if (liveTurnIds > TURN_ID_OVERFLOW_WARN) {
|
|
10083
|
+
this.log.error(
|
|
10084
|
+
{ workspaceId, liveTurnIds, threshold: TURN_ID_OVERFLOW_WARN },
|
|
10085
|
+
"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."
|
|
10086
|
+
);
|
|
10087
|
+
}
|
|
10088
|
+
markDispatched(workspaceId, ev.id);
|
|
10089
|
+
}
|
|
10090
|
+
if (resumedTurnId) {
|
|
10091
|
+
this.log.info(
|
|
10092
|
+
{ workspaceId, eventId: ev.id, turnId },
|
|
10093
|
+
"companion: resuming an interrupted turn under its original id"
|
|
10094
|
+
);
|
|
10095
|
+
}
|
|
10096
|
+
const result = await agent.dispatcher.handle(payload, { turnId });
|
|
9970
10097
|
if (ev.id) markCompleted(workspaceId, ev.id);
|
|
9971
10098
|
if (ev.id) wr.cursor.settle(ev.id);
|
|
9972
10099
|
const durationS = (result.durationMs / 1e3).toFixed(1);
|
|
@@ -10357,7 +10484,7 @@ async function createCompanionRuntime(opts = {}) {
|
|
|
10357
10484
|
opencodeServerUrl: cfg.opencode?.serverUrl,
|
|
10358
10485
|
codex: isCodexEnabled(cfg)
|
|
10359
10486
|
});
|
|
10360
|
-
const instanceId =
|
|
10487
|
+
const instanceId = randomUUID3();
|
|
10361
10488
|
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
10362
10489
|
const pre = readLiveRuntimeState();
|
|
10363
10490
|
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
|
|
@@ -2006,15 +2017,16 @@ var CabaneApi = class {
|
|
|
2006
2017
|
// (transport errors + 5xx), never a 4xx, so no `(turnId, kind)` row is
|
|
2007
2018
|
// double-posted. The caller threads its turn abort signal so a cancel
|
|
2008
2019
|
// mid-commit aborts the in-flight POST rather than letting it land.
|
|
2009
|
-
// CT11: `kind`
|
|
2010
|
-
//
|
|
2011
|
-
//
|
|
2012
|
-
//
|
|
2013
|
-
//
|
|
2014
|
-
//
|
|
2015
|
-
//
|
|
2016
|
-
//
|
|
2017
|
-
//
|
|
2020
|
+
// CT11: `kind` includes `'stopped'` for the terminal cancel marker — same
|
|
2021
|
+
// wire shape as `progress`/`final`, distinguished only by `kind` so the chat
|
|
2022
|
+
// drawer's turn-group renderer treats it as a closing row. CT1295: this
|
|
2023
|
+
// companion no longer WRITES that kind — the server does, in the transaction
|
|
2024
|
+
// that terminalizes the stopped turn — so the value survives here only as
|
|
2025
|
+
// wire vocabulary the server also speaks. `seq` is the companion's per-turn
|
|
2026
|
+
// monotonic counter, stamped on the row so the merged timeline orders the
|
|
2027
|
+
// commit deterministically against the persisted tool/thinking rows. Both
|
|
2028
|
+
// fields are optional on the wire — an older companion that didn't mint seq
|
|
2029
|
+
// still validates (the server defaults to 0).
|
|
2018
2030
|
postTurnMessage(workspaceId, conversationId, body, signal) {
|
|
2019
2031
|
return this.durableCommit(
|
|
2020
2032
|
"message",
|
|
@@ -2318,7 +2330,7 @@ var CursorTracker = class {
|
|
|
2318
2330
|
};
|
|
2319
2331
|
|
|
2320
2332
|
// src/dispatch-dedupe.ts
|
|
2321
|
-
import { mkdirSync as mkdirSync7, readFileSync as readFileSync5, writeFileSync as writeFileSync5, existsSync as existsSync7 } from "fs";
|
|
2333
|
+
import { mkdirSync as mkdirSync7, readFileSync as readFileSync5, writeFileSync as writeFileSync5, renameSync as renameSync3, existsSync as existsSync7 } from "fs";
|
|
2322
2334
|
import { join as join9 } from "path";
|
|
2323
2335
|
var MAX_IDS = 256;
|
|
2324
2336
|
function dir(log) {
|
|
@@ -2358,6 +2370,7 @@ function hasCompleted(workspaceId, eventId) {
|
|
|
2358
2370
|
}
|
|
2359
2371
|
function markCompleted(workspaceId, eventId) {
|
|
2360
2372
|
mark("completed", workspaceId, eventId);
|
|
2373
|
+
forgetTurnId(workspaceId, eventId);
|
|
2361
2374
|
}
|
|
2362
2375
|
var MAX_RESUME_ATTEMPTS = 3;
|
|
2363
2376
|
function resumeDir() {
|
|
@@ -2399,6 +2412,56 @@ function bumpResumeAttempt(workspaceId, eventId) {
|
|
|
2399
2412
|
);
|
|
2400
2413
|
return next;
|
|
2401
2414
|
}
|
|
2415
|
+
function turnDir() {
|
|
2416
|
+
return join9(cabaneDir(), "turns");
|
|
2417
|
+
}
|
|
2418
|
+
function turnPathFor(workspaceId) {
|
|
2419
|
+
return join9(turnDir(), encodeURIComponent(workspaceId));
|
|
2420
|
+
}
|
|
2421
|
+
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;
|
|
2422
|
+
function readTurnIds(workspaceId) {
|
|
2423
|
+
const out = /* @__PURE__ */ new Map();
|
|
2424
|
+
const path = turnPathFor(workspaceId);
|
|
2425
|
+
if (!existsSync7(path)) return out;
|
|
2426
|
+
try {
|
|
2427
|
+
for (const line of readFileSync5(path, "utf8").split("\n")) {
|
|
2428
|
+
const trimmed = line.trim();
|
|
2429
|
+
if (!trimmed) continue;
|
|
2430
|
+
const tab = trimmed.lastIndexOf(" ");
|
|
2431
|
+
if (tab <= 0) continue;
|
|
2432
|
+
const id = trimmed.slice(0, tab);
|
|
2433
|
+
const turnId = trimmed.slice(tab + 1);
|
|
2434
|
+
if (id && TURN_ID_RE.test(turnId)) out.set(id, turnId);
|
|
2435
|
+
}
|
|
2436
|
+
} catch {
|
|
2437
|
+
return out;
|
|
2438
|
+
}
|
|
2439
|
+
return out;
|
|
2440
|
+
}
|
|
2441
|
+
function turnIdForEvent(workspaceId, eventId) {
|
|
2442
|
+
return readTurnIds(workspaceId).get(eventId) ?? null;
|
|
2443
|
+
}
|
|
2444
|
+
var TURN_ID_OVERFLOW_WARN = 8192;
|
|
2445
|
+
function writeTurnIds(workspaceId, turns) {
|
|
2446
|
+
const entries = [...turns.entries()];
|
|
2447
|
+
mkdirSync7(turnDir(), { recursive: true });
|
|
2448
|
+
const path = turnPathFor(workspaceId);
|
|
2449
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
2450
|
+
writeFileSync5(tmp, entries.map(([id, t]) => `${id} ${t}`).join("\n") + "\n", "utf8");
|
|
2451
|
+
renameSync3(tmp, path);
|
|
2452
|
+
return entries.length;
|
|
2453
|
+
}
|
|
2454
|
+
function rememberTurnId(workspaceId, eventId, turnId) {
|
|
2455
|
+
const turns = readTurnIds(workspaceId);
|
|
2456
|
+
if (turns.get(eventId) === turnId) return turns.size;
|
|
2457
|
+
turns.set(eventId, turnId);
|
|
2458
|
+
return writeTurnIds(workspaceId, turns);
|
|
2459
|
+
}
|
|
2460
|
+
function forgetTurnId(workspaceId, eventId) {
|
|
2461
|
+
const turns = readTurnIds(workspaceId);
|
|
2462
|
+
if (!turns.delete(eventId)) return;
|
|
2463
|
+
writeTurnIds(workspaceId, turns);
|
|
2464
|
+
}
|
|
2402
2465
|
function noResume() {
|
|
2403
2466
|
return process.env.CABANE_COMPANION_NO_RESUME === "1";
|
|
2404
2467
|
}
|
|
@@ -3816,7 +3879,7 @@ var BASE_CLAUDE_CODE_CONFORMANCE_FIXTURES = [
|
|
|
3816
3879
|
{
|
|
3817
3880
|
// HIGHEST-RISK: cancel mid-stream. The abort lands after the tool result;
|
|
3818
3881
|
// the adapter stops before the closing reply — no final text, no `result`
|
|
3819
|
-
// event.
|
|
3882
|
+
// event. CT1295: the server writes the `kind:'stopped'` marker.
|
|
3820
3883
|
name: "cancel mid-stream",
|
|
3821
3884
|
request: makeRequest(),
|
|
3822
3885
|
nativeStream: [
|
|
@@ -4937,7 +5000,7 @@ var OPENCODE_CONFORMANCE_FIXTURES = [
|
|
|
4937
5000
|
{
|
|
4938
5001
|
// HIGHEST-RISK: cancel mid-stream. The abort lands after the tool result; the
|
|
4939
5002
|
// adapter stops before the closing reply — no final text, no `result` event.
|
|
4940
|
-
//
|
|
5003
|
+
// CT1295: the server writes the `kind:'stopped'` marker.
|
|
4941
5004
|
name: "cancel mid-stream",
|
|
4942
5005
|
request: makeRequest2(),
|
|
4943
5006
|
nativeStream: [
|
|
@@ -6229,7 +6292,7 @@ var CODEX_CONFORMANCE_FIXTURES = [
|
|
|
6229
6292
|
{
|
|
6230
6293
|
// HIGHEST-RISK: cancel mid-stream. The abort lands after the tool result; the
|
|
6231
6294
|
// adapter stops before the closing reply — no final text, no `result` event.
|
|
6232
|
-
//
|
|
6295
|
+
// CT1295: the server writes the `kind:'stopped'` marker.
|
|
6233
6296
|
name: "cancel mid-stream",
|
|
6234
6297
|
request: makeRequest3(),
|
|
6235
6298
|
nativeStream: [
|
|
@@ -7385,8 +7448,10 @@ var TurnCommitter = class {
|
|
|
7385
7448
|
// End-of-turn progress promotion. The held-text flush is now the adapter's
|
|
7386
7449
|
// job (it emits the closing reply as a `text` event before `result`), so this
|
|
7387
7450
|
// only asks the pump to promote agent-authored interim text. A wordless turn
|
|
7388
|
-
// stays wordless and the dispatcher closes it with a
|
|
7389
|
-
//
|
|
7451
|
+
// stays wordless and the dispatcher closes it with a `silent`/`skipped`
|
|
7452
|
+
// marker (CT1295: a CANCELLED turn is closed by the SERVER's `stopped`
|
|
7453
|
+
// marker, not from this side). Guards on the abort signal, so on cancel no
|
|
7454
|
+
// `final` is forced.
|
|
7390
7455
|
async finalize(okResult) {
|
|
7391
7456
|
await this.pump.finalize(okResult);
|
|
7392
7457
|
}
|
|
@@ -7504,7 +7569,6 @@ var TurnCommitter = class {
|
|
|
7504
7569
|
var PREPARING_TOOL_NAME = "preparing";
|
|
7505
7570
|
var PREPARE_FAILED_PREFIX = "**Couldn't prepare your environment.** I wasn't able to provision a working directory for this conversation, so I can't run this turn. The provisioning command reported:";
|
|
7506
7571
|
var MISSING_SECRET_PREFIX = "**Missing secret on this companion.** This agent's tools need a credential this device hasn't been given, so I can't run this turn safely. Declare it in this companion\u2019s secret store (`~/.cabane/secrets.json`) and try again. Missing:";
|
|
7507
|
-
var STOPPED_MARKER_BODY = "(stopped)";
|
|
7508
7572
|
var RUNTIME_UNAVAILABLE_PREFIX = "**This agent's runtime isn't available on this companion.** The model this agent uses needs a runtime this device isn't running, so I can't run this turn here. Details:";
|
|
7509
7573
|
var UNEXPECTED_ROLE_REASON = `refused the wake trigger (role "system" isn't dispatchable on this companion) \u2014 the companion is likely running outdated code; refresh it, then re-address the agent`;
|
|
7510
7574
|
var SKIPPED_MARKER_BODY = "(skipped)";
|
|
@@ -7539,6 +7603,18 @@ function checkoutState(cwd) {
|
|
|
7539
7603
|
function runKey(conversationId, agentId) {
|
|
7540
7604
|
return `${conversationId}|${agentId}`;
|
|
7541
7605
|
}
|
|
7606
|
+
var LEASE_REFUSALS = /* @__PURE__ */ new Set([
|
|
7607
|
+
"dispatch_not_admitted",
|
|
7608
|
+
"turn_already_ended",
|
|
7609
|
+
"turn_belongs_elsewhere"
|
|
7610
|
+
]);
|
|
7611
|
+
function leaseRefusal(err) {
|
|
7612
|
+
if (!(err instanceof ApiError)) return null;
|
|
7613
|
+
const body = err.body;
|
|
7614
|
+
const code = typeof body === "object" && body !== null && typeof body.error === "string" ? body.error : null;
|
|
7615
|
+
if (code && LEASE_REFUSALS.has(code)) return code;
|
|
7616
|
+
return null;
|
|
7617
|
+
}
|
|
7542
7618
|
var ERROR_BODY_LOG_CAP = 2e3;
|
|
7543
7619
|
function describeErrorBody(body) {
|
|
7544
7620
|
if (body === void 0 || body === null) return void 0;
|
|
@@ -7573,6 +7649,16 @@ var Dispatcher = class {
|
|
|
7573
7649
|
}
|
|
7574
7650
|
opts;
|
|
7575
7651
|
// SJ383: per-(conversation, agent) abort registry.
|
|
7652
|
+
// CT1288: RunKey -> (turnId -> controller). This used to be one controller
|
|
7653
|
+
// per (conversation, agent), which quietly encoded the invariant the whole
|
|
7654
|
+
// task exists to enforce: that a pair can only ever have one live loop. When
|
|
7655
|
+
// that assumption broke, the second `set` EVICTED the first controller and
|
|
7656
|
+
// the first loop became permanently uncancellable — no other code path can
|
|
7657
|
+
// reach into a running turn. So the registry that Stop depends on failed
|
|
7658
|
+
// exactly when Stop was the thing you needed.
|
|
7659
|
+
//
|
|
7660
|
+
// Nesting by turn id costs nothing in the normal single-turn case and makes
|
|
7661
|
+
// `cancel` total: it aborts every loop under the pair, not the newest one.
|
|
7576
7662
|
aborts;
|
|
7577
7663
|
// CT1109: pairs already told, in the conversation, that their session state is
|
|
7578
7664
|
// being refused. The failure repeats every single turn until someone fixes the
|
|
@@ -7635,7 +7721,12 @@ var Dispatcher = class {
|
|
|
7635
7721
|
this.notifyEnd({ id: payload.messageId, ok: false, durationMs, reason });
|
|
7636
7722
|
return { ok: false, durationMs, reason };
|
|
7637
7723
|
}
|
|
7638
|
-
|
|
7724
|
+
// CT1288: `opts.turnId` is a RESUMED turn id — the id this event already ran
|
|
7725
|
+
// under before the companion died. Passing it makes the replay ask the
|
|
7726
|
+
// mailroom to re-admit the turn it already owns, which the server explicitly
|
|
7727
|
+
// supports ("a replay reports, it never re-points"). Omitted on a fresh
|
|
7728
|
+
// dispatch, where minting below is correct.
|
|
7729
|
+
async handle(payload, opts = {}) {
|
|
7639
7730
|
const startedAt = Date.now();
|
|
7640
7731
|
const dispatchId = payload.messageId;
|
|
7641
7732
|
const workspaceId = this.opts.workspaceId;
|
|
@@ -7645,7 +7736,7 @@ var Dispatcher = class {
|
|
|
7645
7736
|
agentId: payload.agentId,
|
|
7646
7737
|
messageId: payload.messageId
|
|
7647
7738
|
});
|
|
7648
|
-
const turnId = randomUUID();
|
|
7739
|
+
const turnId = opts.turnId ?? randomUUID();
|
|
7649
7740
|
let turnContext;
|
|
7650
7741
|
try {
|
|
7651
7742
|
turnContext = await this.opts.api.getTurnContext(
|
|
@@ -7867,7 +7958,18 @@ ${reason}`,
|
|
|
7867
7958
|
};
|
|
7868
7959
|
const key = runKey(payload.conversationId, payload.agentId);
|
|
7869
7960
|
const abortController = new AbortController();
|
|
7870
|
-
this.aborts.
|
|
7961
|
+
let pairAborts = this.aborts.get(key);
|
|
7962
|
+
if (!pairAborts) {
|
|
7963
|
+
pairAborts = /* @__PURE__ */ new Map();
|
|
7964
|
+
this.aborts.set(key, pairAborts);
|
|
7965
|
+
}
|
|
7966
|
+
pairAborts.set(turnId, abortController);
|
|
7967
|
+
const releaseAbort = () => {
|
|
7968
|
+
const pair = this.aborts.get(key);
|
|
7969
|
+
if (pair?.get(turnId) !== abortController) return;
|
|
7970
|
+
pair.delete(turnId);
|
|
7971
|
+
if (pair.size === 0) this.aborts.delete(key);
|
|
7972
|
+
};
|
|
7871
7973
|
let timeoutReason = null;
|
|
7872
7974
|
try {
|
|
7873
7975
|
await this.opts.api.setActiveRun(workspaceId, payload.conversationId, payload.agentId, {
|
|
@@ -7880,10 +7982,21 @@ ${reason}`,
|
|
|
7880
7982
|
turnId
|
|
7881
7983
|
});
|
|
7882
7984
|
} catch (err) {
|
|
7883
|
-
|
|
7884
|
-
|
|
7885
|
-
|
|
7985
|
+
const refusal = leaseRefusal(err);
|
|
7986
|
+
releaseAbort();
|
|
7987
|
+
if (refusal === "dispatch_not_admitted" || refusal === "turn_already_ended") {
|
|
7988
|
+
turnLog.error(
|
|
7989
|
+
{ refusal, turnId },
|
|
7990
|
+
"dispatcher: refused a turn lease; not running the model"
|
|
7991
|
+
);
|
|
7992
|
+
return this.concludeBeforeRun(payload, turnLog, startedAt, `lease_refused: ${refusal}`);
|
|
7993
|
+
}
|
|
7994
|
+
const reason = refusal ? `lease_refused: ${refusal}` : "lease_unconfirmed";
|
|
7995
|
+
turnLog.error(
|
|
7996
|
+
{ refusal, turnId, err: err instanceof Error ? err.message : String(err) },
|
|
7997
|
+
"dispatcher: turn lease not confirmed; not running the model"
|
|
7886
7998
|
);
|
|
7999
|
+
return this.concludeBeforeRun(payload, turnLog, startedAt, reason, reason);
|
|
7887
8000
|
}
|
|
7888
8001
|
const sendState = createSendState();
|
|
7889
8002
|
const skipState = createSkipState();
|
|
@@ -7987,6 +8100,7 @@ ${reason}`,
|
|
|
7987
8100
|
"dispatcher: runtime-unavailable notice post failed"
|
|
7988
8101
|
);
|
|
7989
8102
|
}
|
|
8103
|
+
releaseAbort();
|
|
7990
8104
|
return this.concludeBeforeRun(
|
|
7991
8105
|
payload,
|
|
7992
8106
|
turnLog,
|
|
@@ -8287,23 +8401,6 @@ ${reason}`,
|
|
|
8287
8401
|
}
|
|
8288
8402
|
}
|
|
8289
8403
|
}
|
|
8290
|
-
if (userCancelled) {
|
|
8291
|
-
try {
|
|
8292
|
-
await this.opts.api.postTurnMessage(workspaceId, payload.conversationId, {
|
|
8293
|
-
body: STOPPED_MARKER_BODY,
|
|
8294
|
-
kind: "stopped",
|
|
8295
|
-
turnId,
|
|
8296
|
-
seq: nextSeq(),
|
|
8297
|
-
// CT113: the stopped marker is still "about" the triggering message.
|
|
8298
|
-
parentMessageId: payload.messageId
|
|
8299
|
-
});
|
|
8300
|
-
} catch (err) {
|
|
8301
|
-
turnLog.warn(
|
|
8302
|
-
{ err: err instanceof Error ? err.message : String(err) },
|
|
8303
|
-
"dispatcher: stopped-marker commit failed"
|
|
8304
|
-
);
|
|
8305
|
-
}
|
|
8306
|
-
}
|
|
8307
8404
|
const body = {
|
|
8308
8405
|
activeRunStartedAt: null,
|
|
8309
8406
|
// CT277: the server keys the turn-end metadata UPDATE (ended_at + tokens)
|
|
@@ -8386,9 +8483,7 @@ ${reason}`,
|
|
|
8386
8483
|
"dispatcher: active-run clear failed terminally; server age-sweep is the backstop"
|
|
8387
8484
|
);
|
|
8388
8485
|
}
|
|
8389
|
-
|
|
8390
|
-
this.aborts.delete(key);
|
|
8391
|
-
}
|
|
8486
|
+
releaseAbort();
|
|
8392
8487
|
}
|
|
8393
8488
|
const durationMs = Date.now() - startedAt;
|
|
8394
8489
|
const finalReplyBody = committer.replyBody;
|
|
@@ -8432,11 +8527,13 @@ ${reason}`,
|
|
|
8432
8527
|
// THIS companion process. Returns true if an in-flight run was aborted.
|
|
8433
8528
|
cancel(conversationId, agentId) {
|
|
8434
8529
|
const key = runKey(conversationId, agentId);
|
|
8435
|
-
const
|
|
8436
|
-
if (!
|
|
8437
|
-
|
|
8438
|
-
|
|
8439
|
-
|
|
8530
|
+
const pair = this.aborts.get(key);
|
|
8531
|
+
if (!pair || pair.size === 0) return false;
|
|
8532
|
+
for (const ac of [...pair.values()]) {
|
|
8533
|
+
try {
|
|
8534
|
+
ac.abort();
|
|
8535
|
+
} catch {
|
|
8536
|
+
}
|
|
8440
8537
|
}
|
|
8441
8538
|
return true;
|
|
8442
8539
|
}
|
|
@@ -8500,7 +8597,7 @@ import {
|
|
|
8500
8597
|
mkdirSync as mkdirSync10,
|
|
8501
8598
|
readdirSync as readdirSync3,
|
|
8502
8599
|
readFileSync as readFileSync8,
|
|
8503
|
-
renameSync as
|
|
8600
|
+
renameSync as renameSync4,
|
|
8504
8601
|
rmSync as rmSync7,
|
|
8505
8602
|
writeFileSync as writeFileSync7
|
|
8506
8603
|
} from "fs";
|
|
@@ -8532,7 +8629,7 @@ var Outbox = class {
|
|
|
8532
8629
|
const tmp = `${target}.${process.pid}.tmp`;
|
|
8533
8630
|
try {
|
|
8534
8631
|
writeFileSync7(tmp, JSON.stringify(entry) + "\n", "utf8");
|
|
8535
|
-
|
|
8632
|
+
renameSync4(tmp, target);
|
|
8536
8633
|
} catch (err) {
|
|
8537
8634
|
try {
|
|
8538
8635
|
rmSync7(tmp, { force: true });
|
|
@@ -9333,6 +9430,19 @@ var CompanionSupervisor = class {
|
|
|
9333
9430
|
type: "device:dispatch_requested",
|
|
9334
9431
|
...wire.payload
|
|
9335
9432
|
};
|
|
9433
|
+
if (this.deviceId && payload.deviceId && payload.deviceId !== this.deviceId) {
|
|
9434
|
+
this.log.debug(
|
|
9435
|
+
{
|
|
9436
|
+
workspaceId: wr.workspaceId,
|
|
9437
|
+
conversationId: payload.conversationId,
|
|
9438
|
+
agentId: payload.agentId,
|
|
9439
|
+
addressedTo: payload.deviceId
|
|
9440
|
+
},
|
|
9441
|
+
"companion: dispatch addressed to another device; ignoring"
|
|
9442
|
+
);
|
|
9443
|
+
if (ev.id) wr.cursor.settle(ev.id);
|
|
9444
|
+
return;
|
|
9445
|
+
}
|
|
9336
9446
|
let agent = wr.agents.get(payload.agentId);
|
|
9337
9447
|
if (!agent) {
|
|
9338
9448
|
agent = await this.recoverRacedAgent(wr, payload);
|
|
@@ -9464,8 +9574,25 @@ var CompanionSupervisor = class {
|
|
|
9464
9574
|
"companion: re-dispatching interrupted turn (resume after restart)"
|
|
9465
9575
|
);
|
|
9466
9576
|
}
|
|
9467
|
-
|
|
9468
|
-
const
|
|
9577
|
+
const resumedTurnId = ev.id ? turnIdForEvent(workspaceId, ev.id) : null;
|
|
9578
|
+
const turnId = resumedTurnId ?? randomUUID2();
|
|
9579
|
+
if (ev.id) {
|
|
9580
|
+
const liveTurnIds = rememberTurnId(workspaceId, ev.id, turnId);
|
|
9581
|
+
if (liveTurnIds > TURN_ID_OVERFLOW_WARN) {
|
|
9582
|
+
this.log.error(
|
|
9583
|
+
{ workspaceId, liveTurnIds, threshold: TURN_ID_OVERFLOW_WARN },
|
|
9584
|
+
"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."
|
|
9585
|
+
);
|
|
9586
|
+
}
|
|
9587
|
+
markDispatched(workspaceId, ev.id);
|
|
9588
|
+
}
|
|
9589
|
+
if (resumedTurnId) {
|
|
9590
|
+
this.log.info(
|
|
9591
|
+
{ workspaceId, eventId: ev.id, turnId },
|
|
9592
|
+
"companion: resuming an interrupted turn under its original id"
|
|
9593
|
+
);
|
|
9594
|
+
}
|
|
9595
|
+
const result = await agent.dispatcher.handle(payload, { turnId });
|
|
9469
9596
|
if (ev.id) markCompleted(workspaceId, ev.id);
|
|
9470
9597
|
if (ev.id) wr.cursor.settle(ev.id);
|
|
9471
9598
|
const durationS = (result.durationMs / 1e3).toFixed(1);
|
|
@@ -9856,7 +9983,7 @@ async function createCompanionRuntime(opts = {}) {
|
|
|
9856
9983
|
opencodeServerUrl: cfg.opencode?.serverUrl,
|
|
9857
9984
|
codex: isCodexEnabled(cfg)
|
|
9858
9985
|
});
|
|
9859
|
-
const instanceId =
|
|
9986
|
+
const instanceId = randomUUID3();
|
|
9860
9987
|
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
9861
9988
|
const pre = readLiveRuntimeState();
|
|
9862
9989
|
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.65",
|
|
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",
|