@cabane/companion 0.6.38 → 0.6.40
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 +80 -89
- package/dist/runtime.js +80 -62
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -2676,8 +2676,18 @@ var DeviceApi = class {
|
|
|
2676
2676
|
getAssignments() {
|
|
2677
2677
|
return this.request("GET", "/api/companion/assignments");
|
|
2678
2678
|
}
|
|
2679
|
-
|
|
2680
|
-
|
|
2679
|
+
// CT1146: report that a dispatch addressed to THIS device can't be run, because
|
|
2680
|
+
// the agent isn't one this device runs (and still wasn't after a forced
|
|
2681
|
+
// assignments refresh). The server clears the Working flag, retires the dispatch
|
|
2682
|
+
// and posts an explanatory system message.
|
|
2683
|
+
//
|
|
2684
|
+
// This rides the DEVICE token deliberately: the agent's own credential is
|
|
2685
|
+
// delivered through the device's assignments, so an agent this device doesn't
|
|
2686
|
+
// roster is exactly an agent it holds no credential for. The device token is the
|
|
2687
|
+
// only thing it can present, which is why this lives on `DeviceApi` rather than
|
|
2688
|
+
// the per-agent `CabaneApi`.
|
|
2689
|
+
reportDispatchDrop(body) {
|
|
2690
|
+
return this.request("POST", "/api/companion/dispatch-drop", body);
|
|
2681
2691
|
}
|
|
2682
2692
|
// Per-device liveness ping. Reports the companion build version and the env-var
|
|
2683
2693
|
// names the operator's secret store exposes (never values), so CT30's UI can
|
|
@@ -3458,18 +3468,24 @@ function summarizeToolResult(content) {
|
|
|
3458
3468
|
}
|
|
3459
3469
|
if (Array.isArray(content)) {
|
|
3460
3470
|
const parts = [];
|
|
3471
|
+
let sawContentEntry = false;
|
|
3461
3472
|
for (const raw of content) {
|
|
3462
3473
|
if (typeof raw === "string") {
|
|
3474
|
+
sawContentEntry = true;
|
|
3463
3475
|
parts.push(raw);
|
|
3464
3476
|
} else if (raw && typeof raw === "object") {
|
|
3465
3477
|
const block = raw;
|
|
3478
|
+
if (typeof block.type === "string") sawContentEntry = true;
|
|
3466
3479
|
if (block.type === "text" && typeof block.text === "string") parts.push(block.text);
|
|
3467
3480
|
}
|
|
3468
3481
|
}
|
|
3469
3482
|
const joined = parts.join("\n").trim();
|
|
3470
|
-
|
|
3483
|
+
if (joined.length > 0) return truncate(joined, TOOL_IO_MAX_CHARS);
|
|
3484
|
+
if (sawContentEntry || content.length === 0) return "";
|
|
3471
3485
|
}
|
|
3472
|
-
return "";
|
|
3486
|
+
if (typeof content !== "object") return "";
|
|
3487
|
+
if (!Array.isArray(content) && Object.keys(content).length === 0) return "";
|
|
3488
|
+
return summarizeToolIo(content);
|
|
3473
3489
|
}
|
|
3474
3490
|
function truncate(s, max) {
|
|
3475
3491
|
if (s.length <= max) return s;
|
|
@@ -8999,8 +9015,6 @@ var CompanionSupervisor = class {
|
|
|
8999
9015
|
pollTimer = null;
|
|
9000
9016
|
refreshing = false;
|
|
9001
9017
|
stopped = false;
|
|
9002
|
-
draining = false;
|
|
9003
|
-
restartPending = false;
|
|
9004
9018
|
// CT484: latch so the companion/server version-skew warning is logged once, not
|
|
9005
9019
|
// on every 30s heartbeat.
|
|
9006
9020
|
versionSkewWarned = false;
|
|
@@ -9084,7 +9098,7 @@ var CompanionSupervisor = class {
|
|
|
9084
9098
|
}
|
|
9085
9099
|
// ---- device-level loops ----
|
|
9086
9100
|
kickHeartbeat() {
|
|
9087
|
-
if (this.
|
|
9101
|
+
if (this.inFlightHeartbeat) return;
|
|
9088
9102
|
const pending = this.sendHeartbeat();
|
|
9089
9103
|
this.inFlightHeartbeat = pending;
|
|
9090
9104
|
void pending.finally(() => {
|
|
@@ -9132,8 +9146,7 @@ var CompanionSupervisor = class {
|
|
|
9132
9146
|
// actually landed (`harnessSignals` non-null) — an absent field means "we
|
|
9133
9147
|
// didn't look this beat" and leaves the server's stored suggestion alone,
|
|
9134
9148
|
// the same fail-soft contract `models` keeps.
|
|
9135
|
-
...this.harnessSignals ? { detectedRuntimes: detectedRuntimesFor(deriveHarnessSnapshot(this.harnessSignals)) } : {}
|
|
9136
|
-
...this.restartPending ? { restarting: true } : {}
|
|
9149
|
+
...this.harnessSignals ? { detectedRuntimes: detectedRuntimesFor(deriveHarnessSnapshot(this.harnessSignals)) } : {}
|
|
9137
9150
|
});
|
|
9138
9151
|
this.hub.setDevice({ deviceId: res.deviceId });
|
|
9139
9152
|
this.deviceId = res.deviceId;
|
|
@@ -9489,7 +9502,6 @@ var CompanionSupervisor = class {
|
|
|
9489
9502
|
if (ev.id) wr.cursor.settle(ev.id);
|
|
9490
9503
|
return;
|
|
9491
9504
|
}
|
|
9492
|
-
if (this.draining) return;
|
|
9493
9505
|
const payload = {
|
|
9494
9506
|
type: "device:dispatch_requested",
|
|
9495
9507
|
...wire.payload
|
|
@@ -9498,6 +9510,10 @@ var CompanionSupervisor = class {
|
|
|
9498
9510
|
if (!agent) {
|
|
9499
9511
|
agent = await this.recoverRacedAgent(wr, payload);
|
|
9500
9512
|
if (!agent) {
|
|
9513
|
+
if (this.deviceId && payload.deviceId === this.deviceId) {
|
|
9514
|
+
const reported = await this.reportUnrunnableDispatch(wr, payload);
|
|
9515
|
+
if (!reported) return;
|
|
9516
|
+
}
|
|
9501
9517
|
if (ev.id) wr.cursor.settle(ev.id);
|
|
9502
9518
|
return;
|
|
9503
9519
|
}
|
|
@@ -9519,6 +9535,59 @@ var CompanionSupervisor = class {
|
|
|
9519
9535
|
await tail;
|
|
9520
9536
|
if (wr.chains.get(chainKey) === tail) wr.chains.delete(chainKey);
|
|
9521
9537
|
}
|
|
9538
|
+
// CT1146: tell the server this device cannot run a dispatch it was addressed to.
|
|
9539
|
+
//
|
|
9540
|
+
// Returns whether the report LANDED, because that answer decides whether the
|
|
9541
|
+
// caller may settle the cursor. Nothing else on either side ends this turn: the
|
|
9542
|
+
// server's sweep resolves the agent's effective connector, which in a roster
|
|
9543
|
+
// desync still points at this device, so it is never classified `no_device` and
|
|
9544
|
+
// only the silent 12h age backstop touches it. An unreported drop is therefore
|
|
9545
|
+
// indistinguishable from the original defect.
|
|
9546
|
+
//
|
|
9547
|
+
// Bounded retry, because the common failure is transient (a 503 mid-deploy, a
|
|
9548
|
+
// dropped socket) and one more attempt a second later usually lands. Bounded
|
|
9549
|
+
// hard — this runs inline on the event loop, and an unbounded wait here would
|
|
9550
|
+
// wedge the workspace's whole stream.
|
|
9551
|
+
async reportUnrunnableDispatch(wr, payload) {
|
|
9552
|
+
if (!this.deviceApi) return false;
|
|
9553
|
+
const backoffMs = [0, 1e3, 3e3];
|
|
9554
|
+
let lastErr;
|
|
9555
|
+
for (const wait of backoffMs) {
|
|
9556
|
+
if (this.stopped) return false;
|
|
9557
|
+
if (wait > 0) await new Promise((r) => setTimeout(r, wait));
|
|
9558
|
+
try {
|
|
9559
|
+
const { cleared } = await this.deviceApi.reportDispatchDrop({
|
|
9560
|
+
workspaceId: wr.workspaceId,
|
|
9561
|
+
conversationId: payload.conversationId,
|
|
9562
|
+
agentId: payload.agentId,
|
|
9563
|
+
messageId: payload.messageId,
|
|
9564
|
+
reason: "agent_not_on_device"
|
|
9565
|
+
});
|
|
9566
|
+
this.log.warn(
|
|
9567
|
+
{
|
|
9568
|
+
workspaceId: wr.workspaceId,
|
|
9569
|
+
conversationId: payload.conversationId,
|
|
9570
|
+
agentId: payload.agentId,
|
|
9571
|
+
cleared
|
|
9572
|
+
},
|
|
9573
|
+
"companion: dispatch addressed to this device is for an agent it does not run \u2014 reported as unrunnable"
|
|
9574
|
+
);
|
|
9575
|
+
return true;
|
|
9576
|
+
} catch (err) {
|
|
9577
|
+
lastErr = err;
|
|
9578
|
+
}
|
|
9579
|
+
}
|
|
9580
|
+
this.log.error(
|
|
9581
|
+
{
|
|
9582
|
+
workspaceId: wr.workspaceId,
|
|
9583
|
+
conversationId: payload.conversationId,
|
|
9584
|
+
agentId: payload.agentId,
|
|
9585
|
+
err: lastErr instanceof Error ? lastErr.message : String(lastErr)
|
|
9586
|
+
},
|
|
9587
|
+
"companion: could not report an unrunnable dispatch \u2014 leaving it unsettled so a reconnect replays it"
|
|
9588
|
+
);
|
|
9589
|
+
return false;
|
|
9590
|
+
}
|
|
9522
9591
|
// Pull-on-miss for the assignment-change race: a dispatch arrived for an agent
|
|
9523
9592
|
// we don't yet run. If it's addressed to THIS device, the assignment just
|
|
9524
9593
|
// changed under us (the poll hasn't caught it) — force a fresh assignments pull
|
|
@@ -9794,50 +9863,6 @@ var CompanionSupervisor = class {
|
|
|
9794
9863
|
...[...this.workspaces.values()].flatMap((wr) => wr.sub ? [wr.sub.finished] : [])
|
|
9795
9864
|
]);
|
|
9796
9865
|
}
|
|
9797
|
-
async drainForRestart(graceMs) {
|
|
9798
|
-
this.restartPending = true;
|
|
9799
|
-
if (this.inFlightHeartbeat) await this.inFlightHeartbeat;
|
|
9800
|
-
if (!this.deviceApi) throw new Error("cannot establish deploy drain before pairing");
|
|
9801
|
-
await this.sendHeartbeat();
|
|
9802
|
-
const drainStartedAt = Date.now();
|
|
9803
|
-
const deadline = Date.now() + Math.max(0, graceMs);
|
|
9804
|
-
let timedOut = false;
|
|
9805
|
-
while (true) {
|
|
9806
|
-
const turns = [...this.workspaces.values()].flatMap((wr) => [...wr.chains.values()]);
|
|
9807
|
-
if (turns.length === 0) break;
|
|
9808
|
-
const remainingMs = deadline - Date.now();
|
|
9809
|
-
if (remainingMs <= 0) {
|
|
9810
|
-
timedOut = true;
|
|
9811
|
-
break;
|
|
9812
|
-
}
|
|
9813
|
-
let timer;
|
|
9814
|
-
await Promise.race([
|
|
9815
|
-
Promise.allSettled(turns),
|
|
9816
|
-
new Promise((resolve) => {
|
|
9817
|
-
timer = setTimeout(resolve, remainingMs);
|
|
9818
|
-
timer.unref?.();
|
|
9819
|
-
})
|
|
9820
|
-
]);
|
|
9821
|
-
if (timer) clearTimeout(timer);
|
|
9822
|
-
}
|
|
9823
|
-
this.log.info(
|
|
9824
|
-
{ waitedMs: Date.now() - drainStartedAt, timedOut },
|
|
9825
|
-
timedOut ? "companion: deploy drain grace elapsed; fencing admission for restart" : "companion: deploy drain reached a quiet point; fencing admission for restart"
|
|
9826
|
-
);
|
|
9827
|
-
this.draining = true;
|
|
9828
|
-
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
|
|
9829
|
-
if (this.pollTimer) clearInterval(this.pollTimer);
|
|
9830
|
-
if (this.inFlightHeartbeat) await this.inFlightHeartbeat;
|
|
9831
|
-
await this.deviceApi.beginDrain();
|
|
9832
|
-
for (const wr of this.workspaces.values()) wr.sub?.stop();
|
|
9833
|
-
await Promise.allSettled(
|
|
9834
|
-
[...this.workspaces.values()].flatMap(
|
|
9835
|
-
(wr) => [...wr.agents.values()].map((agent) => agent.api.drainOutbox())
|
|
9836
|
-
)
|
|
9837
|
-
);
|
|
9838
|
-
await this.shutdown();
|
|
9839
|
-
return { drained: !timedOut };
|
|
9840
|
-
}
|
|
9841
9866
|
async requestStop() {
|
|
9842
9867
|
await this.shutdown();
|
|
9843
9868
|
this.exitFn(0);
|
|
@@ -10069,14 +10094,7 @@ async function createCompanionRuntime(opts = {}) {
|
|
|
10069
10094
|
stop: stop2,
|
|
10070
10095
|
heartbeatNow: () => supervisor.heartbeatNow(),
|
|
10071
10096
|
harnesses: () => hub.statusJson().harnesses ?? [],
|
|
10072
|
-
connectHarness
|
|
10073
|
-
drainForRestart: async (graceMs) => {
|
|
10074
|
-
clearRuntimeStateIfOurs(instanceId);
|
|
10075
|
-
const result = await supervisor.drainForRestart(graceMs);
|
|
10076
|
-
await closeSurfaces(control, dashboard);
|
|
10077
|
-
stopped = true;
|
|
10078
|
-
return result;
|
|
10079
|
-
}
|
|
10097
|
+
connectHarness
|
|
10080
10098
|
}
|
|
10081
10099
|
};
|
|
10082
10100
|
}
|
|
@@ -10205,8 +10223,6 @@ function defaultSpawnDetached(args) {
|
|
|
10205
10223
|
|
|
10206
10224
|
// src/commands/start.ts
|
|
10207
10225
|
var FORCE_EXIT_MS = 4e3;
|
|
10208
|
-
var DEPLOY_REEXEC_EXIT = 75;
|
|
10209
|
-
var DEPLOY_GRACE_MS = 6.5 * 60 * 60 * 1e3;
|
|
10210
10226
|
var MAX_CODES = 3;
|
|
10211
10227
|
async function start(opts = {}) {
|
|
10212
10228
|
const interactive = isInteractive();
|
|
@@ -10445,31 +10461,6 @@ companion: received ${signal}, shutting down\u2026
|
|
|
10445
10461
|
};
|
|
10446
10462
|
process.on("SIGINT", () => void shutdown("SIGINT"));
|
|
10447
10463
|
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
|
10448
|
-
process.on("SIGUSR2", () => {
|
|
10449
|
-
if (shuttingDown) return;
|
|
10450
|
-
shuttingDown = true;
|
|
10451
|
-
const configuredGrace = Number.parseInt(
|
|
10452
|
-
process.env.CABANE_COMPANION_DEPLOY_GRACE_MS ?? String(DEPLOY_GRACE_MS),
|
|
10453
|
-
10
|
|
10454
|
-
);
|
|
10455
|
-
const graceMs = Number.isFinite(configuredGrace) ? configuredGrace : DEPLOY_GRACE_MS;
|
|
10456
|
-
process.stdout.write(`
|
|
10457
|
-
companion: deploy drain requested (${graceMs}ms grace)\u2026
|
|
10458
|
-
`);
|
|
10459
|
-
void runtime.drainForRestart(graceMs).then(({ drained }) => {
|
|
10460
|
-
process.stdout.write(
|
|
10461
|
-
drained ? "companion: deploy drain complete; re-execing onto the new dist.\n" : "companion: deploy grace expired; re-execing \u2014 unfinished turns resume after restart.\n"
|
|
10462
|
-
);
|
|
10463
|
-
resolve();
|
|
10464
|
-
process.exit(DEPLOY_REEXEC_EXIT);
|
|
10465
|
-
}).catch((err) => {
|
|
10466
|
-
process.stderr.write(
|
|
10467
|
-
`companion: deploy drain failed: ${err instanceof Error ? err.message : String(err)}
|
|
10468
|
-
`
|
|
10469
|
-
);
|
|
10470
|
-
process.exit(DEPLOY_REEXEC_EXIT);
|
|
10471
|
-
});
|
|
10472
|
-
});
|
|
10473
10464
|
});
|
|
10474
10465
|
}
|
|
10475
10466
|
|
package/dist/runtime.js
CHANGED
|
@@ -2104,8 +2104,18 @@ var DeviceApi = class {
|
|
|
2104
2104
|
getAssignments() {
|
|
2105
2105
|
return this.request("GET", "/api/companion/assignments");
|
|
2106
2106
|
}
|
|
2107
|
-
|
|
2108
|
-
|
|
2107
|
+
// CT1146: report that a dispatch addressed to THIS device can't be run, because
|
|
2108
|
+
// the agent isn't one this device runs (and still wasn't after a forced
|
|
2109
|
+
// assignments refresh). The server clears the Working flag, retires the dispatch
|
|
2110
|
+
// and posts an explanatory system message.
|
|
2111
|
+
//
|
|
2112
|
+
// This rides the DEVICE token deliberately: the agent's own credential is
|
|
2113
|
+
// delivered through the device's assignments, so an agent this device doesn't
|
|
2114
|
+
// roster is exactly an agent it holds no credential for. The device token is the
|
|
2115
|
+
// only thing it can present, which is why this lives on `DeviceApi` rather than
|
|
2116
|
+
// the per-agent `CabaneApi`.
|
|
2117
|
+
reportDispatchDrop(body) {
|
|
2118
|
+
return this.request("POST", "/api/companion/dispatch-drop", body);
|
|
2109
2119
|
}
|
|
2110
2120
|
// Per-device liveness ping. Reports the companion build version and the env-var
|
|
2111
2121
|
// names the operator's secret store exposes (never values), so CT30's UI can
|
|
@@ -2965,18 +2975,24 @@ function summarizeToolResult(content) {
|
|
|
2965
2975
|
}
|
|
2966
2976
|
if (Array.isArray(content)) {
|
|
2967
2977
|
const parts = [];
|
|
2978
|
+
let sawContentEntry = false;
|
|
2968
2979
|
for (const raw of content) {
|
|
2969
2980
|
if (typeof raw === "string") {
|
|
2981
|
+
sawContentEntry = true;
|
|
2970
2982
|
parts.push(raw);
|
|
2971
2983
|
} else if (raw && typeof raw === "object") {
|
|
2972
2984
|
const block = raw;
|
|
2985
|
+
if (typeof block.type === "string") sawContentEntry = true;
|
|
2973
2986
|
if (block.type === "text" && typeof block.text === "string") parts.push(block.text);
|
|
2974
2987
|
}
|
|
2975
2988
|
}
|
|
2976
2989
|
const joined = parts.join("\n").trim();
|
|
2977
|
-
|
|
2990
|
+
if (joined.length > 0) return truncate(joined, TOOL_IO_MAX_CHARS);
|
|
2991
|
+
if (sawContentEntry || content.length === 0) return "";
|
|
2978
2992
|
}
|
|
2979
|
-
return "";
|
|
2993
|
+
if (typeof content !== "object") return "";
|
|
2994
|
+
if (!Array.isArray(content) && Object.keys(content).length === 0) return "";
|
|
2995
|
+
return summarizeToolIo(content);
|
|
2980
2996
|
}
|
|
2981
2997
|
function truncate(s, max) {
|
|
2982
2998
|
if (s.length <= max) return s;
|
|
@@ -8506,8 +8522,6 @@ var CompanionSupervisor = class {
|
|
|
8506
8522
|
pollTimer = null;
|
|
8507
8523
|
refreshing = false;
|
|
8508
8524
|
stopped = false;
|
|
8509
|
-
draining = false;
|
|
8510
|
-
restartPending = false;
|
|
8511
8525
|
// CT484: latch so the companion/server version-skew warning is logged once, not
|
|
8512
8526
|
// on every 30s heartbeat.
|
|
8513
8527
|
versionSkewWarned = false;
|
|
@@ -8591,7 +8605,7 @@ var CompanionSupervisor = class {
|
|
|
8591
8605
|
}
|
|
8592
8606
|
// ---- device-level loops ----
|
|
8593
8607
|
kickHeartbeat() {
|
|
8594
|
-
if (this.
|
|
8608
|
+
if (this.inFlightHeartbeat) return;
|
|
8595
8609
|
const pending = this.sendHeartbeat();
|
|
8596
8610
|
this.inFlightHeartbeat = pending;
|
|
8597
8611
|
void pending.finally(() => {
|
|
@@ -8639,8 +8653,7 @@ var CompanionSupervisor = class {
|
|
|
8639
8653
|
// actually landed (`harnessSignals` non-null) — an absent field means "we
|
|
8640
8654
|
// didn't look this beat" and leaves the server's stored suggestion alone,
|
|
8641
8655
|
// the same fail-soft contract `models` keeps.
|
|
8642
|
-
...this.harnessSignals ? { detectedRuntimes: detectedRuntimesFor(deriveHarnessSnapshot(this.harnessSignals)) } : {}
|
|
8643
|
-
...this.restartPending ? { restarting: true } : {}
|
|
8656
|
+
...this.harnessSignals ? { detectedRuntimes: detectedRuntimesFor(deriveHarnessSnapshot(this.harnessSignals)) } : {}
|
|
8644
8657
|
});
|
|
8645
8658
|
this.hub.setDevice({ deviceId: res.deviceId });
|
|
8646
8659
|
this.deviceId = res.deviceId;
|
|
@@ -8996,7 +9009,6 @@ var CompanionSupervisor = class {
|
|
|
8996
9009
|
if (ev.id) wr.cursor.settle(ev.id);
|
|
8997
9010
|
return;
|
|
8998
9011
|
}
|
|
8999
|
-
if (this.draining) return;
|
|
9000
9012
|
const payload = {
|
|
9001
9013
|
type: "device:dispatch_requested",
|
|
9002
9014
|
...wire.payload
|
|
@@ -9005,6 +9017,10 @@ var CompanionSupervisor = class {
|
|
|
9005
9017
|
if (!agent) {
|
|
9006
9018
|
agent = await this.recoverRacedAgent(wr, payload);
|
|
9007
9019
|
if (!agent) {
|
|
9020
|
+
if (this.deviceId && payload.deviceId === this.deviceId) {
|
|
9021
|
+
const reported = await this.reportUnrunnableDispatch(wr, payload);
|
|
9022
|
+
if (!reported) return;
|
|
9023
|
+
}
|
|
9008
9024
|
if (ev.id) wr.cursor.settle(ev.id);
|
|
9009
9025
|
return;
|
|
9010
9026
|
}
|
|
@@ -9026,6 +9042,59 @@ var CompanionSupervisor = class {
|
|
|
9026
9042
|
await tail;
|
|
9027
9043
|
if (wr.chains.get(chainKey) === tail) wr.chains.delete(chainKey);
|
|
9028
9044
|
}
|
|
9045
|
+
// CT1146: tell the server this device cannot run a dispatch it was addressed to.
|
|
9046
|
+
//
|
|
9047
|
+
// Returns whether the report LANDED, because that answer decides whether the
|
|
9048
|
+
// caller may settle the cursor. Nothing else on either side ends this turn: the
|
|
9049
|
+
// server's sweep resolves the agent's effective connector, which in a roster
|
|
9050
|
+
// desync still points at this device, so it is never classified `no_device` and
|
|
9051
|
+
// only the silent 12h age backstop touches it. An unreported drop is therefore
|
|
9052
|
+
// indistinguishable from the original defect.
|
|
9053
|
+
//
|
|
9054
|
+
// Bounded retry, because the common failure is transient (a 503 mid-deploy, a
|
|
9055
|
+
// dropped socket) and one more attempt a second later usually lands. Bounded
|
|
9056
|
+
// hard — this runs inline on the event loop, and an unbounded wait here would
|
|
9057
|
+
// wedge the workspace's whole stream.
|
|
9058
|
+
async reportUnrunnableDispatch(wr, payload) {
|
|
9059
|
+
if (!this.deviceApi) return false;
|
|
9060
|
+
const backoffMs = [0, 1e3, 3e3];
|
|
9061
|
+
let lastErr;
|
|
9062
|
+
for (const wait of backoffMs) {
|
|
9063
|
+
if (this.stopped) return false;
|
|
9064
|
+
if (wait > 0) await new Promise((r) => setTimeout(r, wait));
|
|
9065
|
+
try {
|
|
9066
|
+
const { cleared } = await this.deviceApi.reportDispatchDrop({
|
|
9067
|
+
workspaceId: wr.workspaceId,
|
|
9068
|
+
conversationId: payload.conversationId,
|
|
9069
|
+
agentId: payload.agentId,
|
|
9070
|
+
messageId: payload.messageId,
|
|
9071
|
+
reason: "agent_not_on_device"
|
|
9072
|
+
});
|
|
9073
|
+
this.log.warn(
|
|
9074
|
+
{
|
|
9075
|
+
workspaceId: wr.workspaceId,
|
|
9076
|
+
conversationId: payload.conversationId,
|
|
9077
|
+
agentId: payload.agentId,
|
|
9078
|
+
cleared
|
|
9079
|
+
},
|
|
9080
|
+
"companion: dispatch addressed to this device is for an agent it does not run \u2014 reported as unrunnable"
|
|
9081
|
+
);
|
|
9082
|
+
return true;
|
|
9083
|
+
} catch (err) {
|
|
9084
|
+
lastErr = err;
|
|
9085
|
+
}
|
|
9086
|
+
}
|
|
9087
|
+
this.log.error(
|
|
9088
|
+
{
|
|
9089
|
+
workspaceId: wr.workspaceId,
|
|
9090
|
+
conversationId: payload.conversationId,
|
|
9091
|
+
agentId: payload.agentId,
|
|
9092
|
+
err: lastErr instanceof Error ? lastErr.message : String(lastErr)
|
|
9093
|
+
},
|
|
9094
|
+
"companion: could not report an unrunnable dispatch \u2014 leaving it unsettled so a reconnect replays it"
|
|
9095
|
+
);
|
|
9096
|
+
return false;
|
|
9097
|
+
}
|
|
9029
9098
|
// Pull-on-miss for the assignment-change race: a dispatch arrived for an agent
|
|
9030
9099
|
// we don't yet run. If it's addressed to THIS device, the assignment just
|
|
9031
9100
|
// changed under us (the poll hasn't caught it) — force a fresh assignments pull
|
|
@@ -9301,50 +9370,6 @@ var CompanionSupervisor = class {
|
|
|
9301
9370
|
...[...this.workspaces.values()].flatMap((wr) => wr.sub ? [wr.sub.finished] : [])
|
|
9302
9371
|
]);
|
|
9303
9372
|
}
|
|
9304
|
-
async drainForRestart(graceMs) {
|
|
9305
|
-
this.restartPending = true;
|
|
9306
|
-
if (this.inFlightHeartbeat) await this.inFlightHeartbeat;
|
|
9307
|
-
if (!this.deviceApi) throw new Error("cannot establish deploy drain before pairing");
|
|
9308
|
-
await this.sendHeartbeat();
|
|
9309
|
-
const drainStartedAt = Date.now();
|
|
9310
|
-
const deadline = Date.now() + Math.max(0, graceMs);
|
|
9311
|
-
let timedOut = false;
|
|
9312
|
-
while (true) {
|
|
9313
|
-
const turns = [...this.workspaces.values()].flatMap((wr) => [...wr.chains.values()]);
|
|
9314
|
-
if (turns.length === 0) break;
|
|
9315
|
-
const remainingMs = deadline - Date.now();
|
|
9316
|
-
if (remainingMs <= 0) {
|
|
9317
|
-
timedOut = true;
|
|
9318
|
-
break;
|
|
9319
|
-
}
|
|
9320
|
-
let timer;
|
|
9321
|
-
await Promise.race([
|
|
9322
|
-
Promise.allSettled(turns),
|
|
9323
|
-
new Promise((resolve) => {
|
|
9324
|
-
timer = setTimeout(resolve, remainingMs);
|
|
9325
|
-
timer.unref?.();
|
|
9326
|
-
})
|
|
9327
|
-
]);
|
|
9328
|
-
if (timer) clearTimeout(timer);
|
|
9329
|
-
}
|
|
9330
|
-
this.log.info(
|
|
9331
|
-
{ waitedMs: Date.now() - drainStartedAt, timedOut },
|
|
9332
|
-
timedOut ? "companion: deploy drain grace elapsed; fencing admission for restart" : "companion: deploy drain reached a quiet point; fencing admission for restart"
|
|
9333
|
-
);
|
|
9334
|
-
this.draining = true;
|
|
9335
|
-
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
|
|
9336
|
-
if (this.pollTimer) clearInterval(this.pollTimer);
|
|
9337
|
-
if (this.inFlightHeartbeat) await this.inFlightHeartbeat;
|
|
9338
|
-
await this.deviceApi.beginDrain();
|
|
9339
|
-
for (const wr of this.workspaces.values()) wr.sub?.stop();
|
|
9340
|
-
await Promise.allSettled(
|
|
9341
|
-
[...this.workspaces.values()].flatMap(
|
|
9342
|
-
(wr) => [...wr.agents.values()].map((agent) => agent.api.drainOutbox())
|
|
9343
|
-
)
|
|
9344
|
-
);
|
|
9345
|
-
await this.shutdown();
|
|
9346
|
-
return { drained: !timedOut };
|
|
9347
|
-
}
|
|
9348
9373
|
async requestStop() {
|
|
9349
9374
|
await this.shutdown();
|
|
9350
9375
|
this.exitFn(0);
|
|
@@ -9576,14 +9601,7 @@ async function createCompanionRuntime(opts = {}) {
|
|
|
9576
9601
|
stop,
|
|
9577
9602
|
heartbeatNow: () => supervisor.heartbeatNow(),
|
|
9578
9603
|
harnesses: () => hub.statusJson().harnesses ?? [],
|
|
9579
|
-
connectHarness
|
|
9580
|
-
drainForRestart: async (graceMs) => {
|
|
9581
|
-
clearRuntimeStateIfOurs(instanceId);
|
|
9582
|
-
const result = await supervisor.drainForRestart(graceMs);
|
|
9583
|
-
await closeSurfaces(control, dashboard);
|
|
9584
|
-
stopped = true;
|
|
9585
|
-
return result;
|
|
9586
|
-
}
|
|
9604
|
+
connectHarness
|
|
9587
9605
|
}
|
|
9588
9606
|
};
|
|
9589
9607
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cabane/companion",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.40",
|
|
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",
|