@cabane/companion 0.6.37 → 0.6.39
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 +151 -7
- package/dist/runtime.js +151 -7
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -2679,6 +2679,19 @@ var DeviceApi = class {
|
|
|
2679
2679
|
beginDrain() {
|
|
2680
2680
|
return this.request("POST", "/api/companion/drain", {});
|
|
2681
2681
|
}
|
|
2682
|
+
// CT1146: report that a dispatch addressed to THIS device can't be run, because
|
|
2683
|
+
// the agent isn't one this device runs (and still wasn't after a forced
|
|
2684
|
+
// assignments refresh). The server clears the Working flag, retires the dispatch
|
|
2685
|
+
// and posts an explanatory system message.
|
|
2686
|
+
//
|
|
2687
|
+
// This rides the DEVICE token deliberately: the agent's own credential is
|
|
2688
|
+
// delivered through the device's assignments, so an agent this device doesn't
|
|
2689
|
+
// roster is exactly an agent it holds no credential for. The device token is the
|
|
2690
|
+
// only thing it can present, which is why this lives on `DeviceApi` rather than
|
|
2691
|
+
// the per-agent `CabaneApi`.
|
|
2692
|
+
reportDispatchDrop(body) {
|
|
2693
|
+
return this.request("POST", "/api/companion/dispatch-drop", body);
|
|
2694
|
+
}
|
|
2682
2695
|
// Per-device liveness ping. Reports the companion build version and the env-var
|
|
2683
2696
|
// names the operator's secret store exposes (never values), so CT30's UI can
|
|
2684
2697
|
// warn pre-emptively about an agent that needs a secret this device lacks.
|
|
@@ -3177,6 +3190,28 @@ function normalizeTurnResultReason(reason) {
|
|
|
3177
3190
|
}
|
|
3178
3191
|
}
|
|
3179
3192
|
|
|
3193
|
+
// packages/agent-runtime/src/empty-result.ts
|
|
3194
|
+
function normalizeCommitText(text) {
|
|
3195
|
+
return text.trim();
|
|
3196
|
+
}
|
|
3197
|
+
function isContentBearingEvent(event) {
|
|
3198
|
+
switch (event.type) {
|
|
3199
|
+
case "text":
|
|
3200
|
+
return normalizeCommitText(event.body) !== "";
|
|
3201
|
+
case "thinking":
|
|
3202
|
+
return normalizeCommitText(event.text) !== "";
|
|
3203
|
+
case "tool":
|
|
3204
|
+
return true;
|
|
3205
|
+
case "session":
|
|
3206
|
+
case "result":
|
|
3207
|
+
return false;
|
|
3208
|
+
}
|
|
3209
|
+
}
|
|
3210
|
+
function classifyEmptyResult(input) {
|
|
3211
|
+
if (!input.ok || input.contentBearingEvents > 0) return null;
|
|
3212
|
+
return input.usage?.inputTokens === 0 && input.usage.outputTokens === 0 ? "empty_result" : "empty_result_unverified";
|
|
3213
|
+
}
|
|
3214
|
+
|
|
3180
3215
|
// packages/agent-runtime/src/turn-request.ts
|
|
3181
3216
|
import { z as z8 } from "zod";
|
|
3182
3217
|
var contentBlockSchema = z8.discriminatedUnion("type", [
|
|
@@ -3436,18 +3471,24 @@ function summarizeToolResult(content) {
|
|
|
3436
3471
|
}
|
|
3437
3472
|
if (Array.isArray(content)) {
|
|
3438
3473
|
const parts = [];
|
|
3474
|
+
let sawContentEntry = false;
|
|
3439
3475
|
for (const raw of content) {
|
|
3440
3476
|
if (typeof raw === "string") {
|
|
3477
|
+
sawContentEntry = true;
|
|
3441
3478
|
parts.push(raw);
|
|
3442
3479
|
} else if (raw && typeof raw === "object") {
|
|
3443
3480
|
const block = raw;
|
|
3481
|
+
if (typeof block.type === "string") sawContentEntry = true;
|
|
3444
3482
|
if (block.type === "text" && typeof block.text === "string") parts.push(block.text);
|
|
3445
3483
|
}
|
|
3446
3484
|
}
|
|
3447
3485
|
const joined = parts.join("\n").trim();
|
|
3448
|
-
|
|
3486
|
+
if (joined.length > 0) return truncate(joined, TOOL_IO_MAX_CHARS);
|
|
3487
|
+
if (sawContentEntry || content.length === 0) return "";
|
|
3449
3488
|
}
|
|
3450
|
-
return "";
|
|
3489
|
+
if (typeof content !== "object") return "";
|
|
3490
|
+
if (!Array.isArray(content) && Object.keys(content).length === 0) return "";
|
|
3491
|
+
return summarizeToolIo(content);
|
|
3451
3492
|
}
|
|
3452
3493
|
function truncate(s, max) {
|
|
3453
3494
|
if (s.length <= max) return s;
|
|
@@ -3498,7 +3539,7 @@ var TurnPump = class {
|
|
|
3498
3539
|
this.sink = {
|
|
3499
3540
|
onAssistantText: async (evt) => {
|
|
3500
3541
|
if (opts.signal.aborted) return;
|
|
3501
|
-
const body = evt.text
|
|
3542
|
+
const body = normalizeCommitText(evt.text);
|
|
3502
3543
|
if (!body) return;
|
|
3503
3544
|
const kind = evt.final ? "final" : "progress";
|
|
3504
3545
|
const seq = opts.nextSeq();
|
|
@@ -3529,7 +3570,7 @@ var TurnPump = class {
|
|
|
3529
3570
|
},
|
|
3530
3571
|
onThinking: async (evt) => {
|
|
3531
3572
|
if (opts.signal.aborted) return;
|
|
3532
|
-
const trimmed = evt.text
|
|
3573
|
+
const trimmed = normalizeCommitText(evt.text);
|
|
3533
3574
|
if (!trimmed) return;
|
|
3534
3575
|
const text = truncate(trimmed, THINKING_TEXT_MAX_CHARS);
|
|
3535
3576
|
const seq = opts.nextSeq();
|
|
@@ -4207,6 +4248,19 @@ var CLAUDE_CODE_CONFORMANCE_FIXTURES = [
|
|
|
4207
4248
|
{ type: "result", ok: false, reason: "result_error:error_max_turns" }
|
|
4208
4249
|
]
|
|
4209
4250
|
},
|
|
4251
|
+
{
|
|
4252
|
+
// CT1144: the empty-runtime anomaly's exact captured trace — `system/init`
|
|
4253
|
+
// then a successful `result`, with nothing in between. Distinct from
|
|
4254
|
+
// `empty-final` below: THAT turn did work (a tool ran) and just didn't speak;
|
|
4255
|
+
// this one produced nothing at all. The adapter's job is unchanged either way
|
|
4256
|
+
// — report faithfully what the runtime said — so it must still emit
|
|
4257
|
+
// `result{ok:true}` here; the host is what decides that a success with no
|
|
4258
|
+
// content-bearing event is a failed turn.
|
|
4259
|
+
name: "zero-content success (empty runtime result)",
|
|
4260
|
+
request: makeRequest(),
|
|
4261
|
+
nativeStream: [init("s1"), resultSuccess("s1")],
|
|
4262
|
+
expected: [sessionEvent("s1"), { type: "result", ok: true }]
|
|
4263
|
+
},
|
|
4210
4264
|
{
|
|
4211
4265
|
// Empty-final: a clean turn that ended on a tool call with no closing text.
|
|
4212
4266
|
// The adapter emits NO final text — empty-final promotion is host/pump
|
|
@@ -5243,6 +5297,18 @@ var OPENCODE_CONFORMANCE_FIXTURES = [
|
|
|
5243
5297
|
],
|
|
5244
5298
|
expected: [sessionEvent2(NEW_SESSION_ID), { type: "result", ok: false, reason: "auth_expired" }]
|
|
5245
5299
|
},
|
|
5300
|
+
{
|
|
5301
|
+
// CT1144: the same zero-content success the claude-code suite pins, in
|
|
5302
|
+
// opencode's vocabulary — the session goes idle having emitted no part at
|
|
5303
|
+
// all. No opencode sample of the anomaly has been seen in the wild; the
|
|
5304
|
+
// fixture exists because the host's classification is runtime-neutral, so a
|
|
5305
|
+
// runtime that CAN produce this envelope must be shown producing it in the
|
|
5306
|
+
// shape the classifier reads.
|
|
5307
|
+
name: "zero-content success (empty runtime result)",
|
|
5308
|
+
request: makeRequest2(),
|
|
5309
|
+
nativeStream: [idle()],
|
|
5310
|
+
expected: [sessionEvent2(NEW_SESSION_ID), { type: "result", ok: true }]
|
|
5311
|
+
},
|
|
5246
5312
|
{
|
|
5247
5313
|
// Empty-final: a clean turn that ended on a tool call with no closing text.
|
|
5248
5314
|
// The adapter emits NO final text — empty-final promotion is host/pump
|
|
@@ -6357,6 +6423,18 @@ var CODEX_CONFORMANCE_FIXTURES = [
|
|
|
6357
6423
|
{ type: "result", ok: true }
|
|
6358
6424
|
]
|
|
6359
6425
|
},
|
|
6426
|
+
{
|
|
6427
|
+
// CT1144: the same zero-content success the claude-code suite pins, in
|
|
6428
|
+
// Codex's vocabulary — the thread starts, the turn completes, and nothing is
|
|
6429
|
+
// emitted in between. No Codex sample of the anomaly has been seen in the
|
|
6430
|
+
// wild; the fixture exists because the host's classification is runtime-
|
|
6431
|
+
// neutral, so a runtime that CAN produce this envelope must be shown
|
|
6432
|
+
// producing it in the shape the classifier reads.
|
|
6433
|
+
name: "zero-content success (empty runtime result)",
|
|
6434
|
+
request: makeRequest3(),
|
|
6435
|
+
nativeStream: [threadStarted(NEW_THREAD_ID), turnCompleted()],
|
|
6436
|
+
expected: [sessionEvent3(NEW_THREAD_ID), { type: "result", ok: true }]
|
|
6437
|
+
},
|
|
6360
6438
|
{
|
|
6361
6439
|
// Empty-final: a clean turn that ended on a tool call with no closing message.
|
|
6362
6440
|
// The adapter emits NO final text — empty-final promotion is host/pump territory
|
|
@@ -8162,6 +8240,7 @@ ${reason}`,
|
|
|
8162
8240
|
result: 0
|
|
8163
8241
|
};
|
|
8164
8242
|
let runtimeResultKind = null;
|
|
8243
|
+
let contentBearingEvents = 0;
|
|
8165
8244
|
let latestSessionState = request.session;
|
|
8166
8245
|
let settledDiagnostics = null;
|
|
8167
8246
|
const committer = new TurnCommitter({
|
|
@@ -8260,6 +8339,7 @@ ${reason}`,
|
|
|
8260
8339
|
for await (const event of adapter.runTurn(request, abortController.signal)) {
|
|
8261
8340
|
transcript2?.write(event);
|
|
8262
8341
|
eventCounts[event.type] += 1;
|
|
8342
|
+
if (isContentBearingEvent(event)) contentBearingEvents += 1;
|
|
8263
8343
|
armIdle();
|
|
8264
8344
|
if (abortController.signal.aborted) {
|
|
8265
8345
|
turnLog.info("dispatcher: aborted mid-turn");
|
|
@@ -8320,6 +8400,11 @@ ${reason}`,
|
|
|
8320
8400
|
okResult = false;
|
|
8321
8401
|
resultReason = timeoutReason ?? "cancelled";
|
|
8322
8402
|
}
|
|
8403
|
+
const emptyResultReason = !skipState.skipped && classifyEmptyResult({ ok: okResult, contentBearingEvents, usage: turnUsage });
|
|
8404
|
+
if (emptyResultReason) {
|
|
8405
|
+
okResult = false;
|
|
8406
|
+
resultReason = emptyResultReason;
|
|
8407
|
+
}
|
|
8323
8408
|
if (!okResult && !resultReason) {
|
|
8324
8409
|
resultReason = "no_result";
|
|
8325
8410
|
}
|
|
@@ -8431,9 +8516,7 @@ ${reason}`,
|
|
|
8431
8516
|
this.sessionWriteNotified.add(key);
|
|
8432
8517
|
}
|
|
8433
8518
|
const outcome = skipState.skipped ? "skipped" : userCancelled ? "cancelled" : okResult ? "success" : "failure";
|
|
8434
|
-
const
|
|
8435
|
-
const emptySuccessfulEnvelope = outcome === "success" && eventCounts.result > 0 && noContentEvents;
|
|
8436
|
-
const diagnosticReason = outcome === "skipped" ? { kind: "skipped" } : outcome === "cancelled" ? { kind: "cancelled" } : emptySuccessfulEnvelope ? turnUsage?.inputTokens === 0 && turnUsage.outputTokens === 0 ? { kind: "empty_result" } : { kind: "empty_result_unverified" } : outcome === "failure" ? normalizeTurnResultReason(resultReason) : null;
|
|
8519
|
+
const diagnosticReason = outcome === "skipped" ? { kind: "skipped" } : outcome === "cancelled" ? { kind: "cancelled" } : outcome === "failure" ? normalizeTurnResultReason(resultReason) : null;
|
|
8437
8520
|
settledDiagnostics = {
|
|
8438
8521
|
outcome,
|
|
8439
8522
|
resultReason: diagnosticReason,
|
|
@@ -8449,10 +8532,14 @@ ${reason}`,
|
|
|
8449
8532
|
)) {
|
|
8450
8533
|
turnLog.warn(
|
|
8451
8534
|
{
|
|
8535
|
+
workspaceId,
|
|
8452
8536
|
turnId,
|
|
8453
8537
|
conversationId: payload.conversationId,
|
|
8454
8538
|
agentId: payload.agentId,
|
|
8455
8539
|
runtime: turnRuntime,
|
|
8540
|
+
model: turnResolvedModel ?? null,
|
|
8541
|
+
usage: turnUsage ?? null,
|
|
8542
|
+
hadStoredSession: request.session != null,
|
|
8456
8543
|
diagnostics: settledDiagnostics
|
|
8457
8544
|
},
|
|
8458
8545
|
"dispatcher: anomalous turn settled"
|
|
@@ -9430,6 +9517,10 @@ var CompanionSupervisor = class {
|
|
|
9430
9517
|
if (!agent) {
|
|
9431
9518
|
agent = await this.recoverRacedAgent(wr, payload);
|
|
9432
9519
|
if (!agent) {
|
|
9520
|
+
if (this.deviceId && payload.deviceId === this.deviceId) {
|
|
9521
|
+
const reported = await this.reportUnrunnableDispatch(wr, payload);
|
|
9522
|
+
if (!reported) return;
|
|
9523
|
+
}
|
|
9433
9524
|
if (ev.id) wr.cursor.settle(ev.id);
|
|
9434
9525
|
return;
|
|
9435
9526
|
}
|
|
@@ -9451,6 +9542,59 @@ var CompanionSupervisor = class {
|
|
|
9451
9542
|
await tail;
|
|
9452
9543
|
if (wr.chains.get(chainKey) === tail) wr.chains.delete(chainKey);
|
|
9453
9544
|
}
|
|
9545
|
+
// CT1146: tell the server this device cannot run a dispatch it was addressed to.
|
|
9546
|
+
//
|
|
9547
|
+
// Returns whether the report LANDED, because that answer decides whether the
|
|
9548
|
+
// caller may settle the cursor. Nothing else on either side ends this turn: the
|
|
9549
|
+
// server's sweep resolves the agent's effective connector, which in a roster
|
|
9550
|
+
// desync still points at this device, so it is never classified `no_device` and
|
|
9551
|
+
// only the silent 12h age backstop touches it. An unreported drop is therefore
|
|
9552
|
+
// indistinguishable from the original defect.
|
|
9553
|
+
//
|
|
9554
|
+
// Bounded retry, because the common failure is transient (a 503 mid-deploy, a
|
|
9555
|
+
// dropped socket) and one more attempt a second later usually lands. Bounded
|
|
9556
|
+
// hard — this runs inline on the event loop, and an unbounded wait here would
|
|
9557
|
+
// wedge the workspace's whole stream.
|
|
9558
|
+
async reportUnrunnableDispatch(wr, payload) {
|
|
9559
|
+
if (!this.deviceApi) return false;
|
|
9560
|
+
const backoffMs = [0, 1e3, 3e3];
|
|
9561
|
+
let lastErr;
|
|
9562
|
+
for (const wait of backoffMs) {
|
|
9563
|
+
if (this.stopped) return false;
|
|
9564
|
+
if (wait > 0) await new Promise((r) => setTimeout(r, wait));
|
|
9565
|
+
try {
|
|
9566
|
+
const { cleared } = await this.deviceApi.reportDispatchDrop({
|
|
9567
|
+
workspaceId: wr.workspaceId,
|
|
9568
|
+
conversationId: payload.conversationId,
|
|
9569
|
+
agentId: payload.agentId,
|
|
9570
|
+
messageId: payload.messageId,
|
|
9571
|
+
reason: "agent_not_on_device"
|
|
9572
|
+
});
|
|
9573
|
+
this.log.warn(
|
|
9574
|
+
{
|
|
9575
|
+
workspaceId: wr.workspaceId,
|
|
9576
|
+
conversationId: payload.conversationId,
|
|
9577
|
+
agentId: payload.agentId,
|
|
9578
|
+
cleared
|
|
9579
|
+
},
|
|
9580
|
+
"companion: dispatch addressed to this device is for an agent it does not run \u2014 reported as unrunnable"
|
|
9581
|
+
);
|
|
9582
|
+
return true;
|
|
9583
|
+
} catch (err) {
|
|
9584
|
+
lastErr = err;
|
|
9585
|
+
}
|
|
9586
|
+
}
|
|
9587
|
+
this.log.error(
|
|
9588
|
+
{
|
|
9589
|
+
workspaceId: wr.workspaceId,
|
|
9590
|
+
conversationId: payload.conversationId,
|
|
9591
|
+
agentId: payload.agentId,
|
|
9592
|
+
err: lastErr instanceof Error ? lastErr.message : String(lastErr)
|
|
9593
|
+
},
|
|
9594
|
+
"companion: could not report an unrunnable dispatch \u2014 leaving it unsettled so a reconnect replays it"
|
|
9595
|
+
);
|
|
9596
|
+
return false;
|
|
9597
|
+
}
|
|
9454
9598
|
// Pull-on-miss for the assignment-change race: a dispatch arrived for an agent
|
|
9455
9599
|
// we don't yet run. If it's addressed to THIS device, the assignment just
|
|
9456
9600
|
// changed under us (the poll hasn't caught it) — force a fresh assignments pull
|
package/dist/runtime.js
CHANGED
|
@@ -2107,6 +2107,19 @@ var DeviceApi = class {
|
|
|
2107
2107
|
beginDrain() {
|
|
2108
2108
|
return this.request("POST", "/api/companion/drain", {});
|
|
2109
2109
|
}
|
|
2110
|
+
// CT1146: report that a dispatch addressed to THIS device can't be run, because
|
|
2111
|
+
// the agent isn't one this device runs (and still wasn't after a forced
|
|
2112
|
+
// assignments refresh). The server clears the Working flag, retires the dispatch
|
|
2113
|
+
// and posts an explanatory system message.
|
|
2114
|
+
//
|
|
2115
|
+
// This rides the DEVICE token deliberately: the agent's own credential is
|
|
2116
|
+
// delivered through the device's assignments, so an agent this device doesn't
|
|
2117
|
+
// roster is exactly an agent it holds no credential for. The device token is the
|
|
2118
|
+
// only thing it can present, which is why this lives on `DeviceApi` rather than
|
|
2119
|
+
// the per-agent `CabaneApi`.
|
|
2120
|
+
reportDispatchDrop(body) {
|
|
2121
|
+
return this.request("POST", "/api/companion/dispatch-drop", body);
|
|
2122
|
+
}
|
|
2110
2123
|
// Per-device liveness ping. Reports the companion build version and the env-var
|
|
2111
2124
|
// names the operator's secret store exposes (never values), so CT30's UI can
|
|
2112
2125
|
// warn pre-emptively about an agent that needs a secret this device lacks.
|
|
@@ -2684,6 +2697,28 @@ function normalizeTurnResultReason(reason) {
|
|
|
2684
2697
|
}
|
|
2685
2698
|
}
|
|
2686
2699
|
|
|
2700
|
+
// packages/agent-runtime/src/empty-result.ts
|
|
2701
|
+
function normalizeCommitText(text) {
|
|
2702
|
+
return text.trim();
|
|
2703
|
+
}
|
|
2704
|
+
function isContentBearingEvent(event) {
|
|
2705
|
+
switch (event.type) {
|
|
2706
|
+
case "text":
|
|
2707
|
+
return normalizeCommitText(event.body) !== "";
|
|
2708
|
+
case "thinking":
|
|
2709
|
+
return normalizeCommitText(event.text) !== "";
|
|
2710
|
+
case "tool":
|
|
2711
|
+
return true;
|
|
2712
|
+
case "session":
|
|
2713
|
+
case "result":
|
|
2714
|
+
return false;
|
|
2715
|
+
}
|
|
2716
|
+
}
|
|
2717
|
+
function classifyEmptyResult(input) {
|
|
2718
|
+
if (!input.ok || input.contentBearingEvents > 0) return null;
|
|
2719
|
+
return input.usage?.inputTokens === 0 && input.usage.outputTokens === 0 ? "empty_result" : "empty_result_unverified";
|
|
2720
|
+
}
|
|
2721
|
+
|
|
2687
2722
|
// packages/agent-runtime/src/turn-request.ts
|
|
2688
2723
|
import { z as z8 } from "zod";
|
|
2689
2724
|
var contentBlockSchema = z8.discriminatedUnion("type", [
|
|
@@ -2943,18 +2978,24 @@ function summarizeToolResult(content) {
|
|
|
2943
2978
|
}
|
|
2944
2979
|
if (Array.isArray(content)) {
|
|
2945
2980
|
const parts = [];
|
|
2981
|
+
let sawContentEntry = false;
|
|
2946
2982
|
for (const raw of content) {
|
|
2947
2983
|
if (typeof raw === "string") {
|
|
2984
|
+
sawContentEntry = true;
|
|
2948
2985
|
parts.push(raw);
|
|
2949
2986
|
} else if (raw && typeof raw === "object") {
|
|
2950
2987
|
const block = raw;
|
|
2988
|
+
if (typeof block.type === "string") sawContentEntry = true;
|
|
2951
2989
|
if (block.type === "text" && typeof block.text === "string") parts.push(block.text);
|
|
2952
2990
|
}
|
|
2953
2991
|
}
|
|
2954
2992
|
const joined = parts.join("\n").trim();
|
|
2955
|
-
|
|
2993
|
+
if (joined.length > 0) return truncate(joined, TOOL_IO_MAX_CHARS);
|
|
2994
|
+
if (sawContentEntry || content.length === 0) return "";
|
|
2956
2995
|
}
|
|
2957
|
-
return "";
|
|
2996
|
+
if (typeof content !== "object") return "";
|
|
2997
|
+
if (!Array.isArray(content) && Object.keys(content).length === 0) return "";
|
|
2998
|
+
return summarizeToolIo(content);
|
|
2958
2999
|
}
|
|
2959
3000
|
function truncate(s, max) {
|
|
2960
3001
|
if (s.length <= max) return s;
|
|
@@ -3005,7 +3046,7 @@ var TurnPump = class {
|
|
|
3005
3046
|
this.sink = {
|
|
3006
3047
|
onAssistantText: async (evt) => {
|
|
3007
3048
|
if (opts.signal.aborted) return;
|
|
3008
|
-
const body = evt.text
|
|
3049
|
+
const body = normalizeCommitText(evt.text);
|
|
3009
3050
|
if (!body) return;
|
|
3010
3051
|
const kind = evt.final ? "final" : "progress";
|
|
3011
3052
|
const seq = opts.nextSeq();
|
|
@@ -3036,7 +3077,7 @@ var TurnPump = class {
|
|
|
3036
3077
|
},
|
|
3037
3078
|
onThinking: async (evt) => {
|
|
3038
3079
|
if (opts.signal.aborted) return;
|
|
3039
|
-
const trimmed = evt.text
|
|
3080
|
+
const trimmed = normalizeCommitText(evt.text);
|
|
3040
3081
|
if (!trimmed) return;
|
|
3041
3082
|
const text = truncate(trimmed, THINKING_TEXT_MAX_CHARS);
|
|
3042
3083
|
const seq = opts.nextSeq();
|
|
@@ -3714,6 +3755,19 @@ var CLAUDE_CODE_CONFORMANCE_FIXTURES = [
|
|
|
3714
3755
|
{ type: "result", ok: false, reason: "result_error:error_max_turns" }
|
|
3715
3756
|
]
|
|
3716
3757
|
},
|
|
3758
|
+
{
|
|
3759
|
+
// CT1144: the empty-runtime anomaly's exact captured trace — `system/init`
|
|
3760
|
+
// then a successful `result`, with nothing in between. Distinct from
|
|
3761
|
+
// `empty-final` below: THAT turn did work (a tool ran) and just didn't speak;
|
|
3762
|
+
// this one produced nothing at all. The adapter's job is unchanged either way
|
|
3763
|
+
// — report faithfully what the runtime said — so it must still emit
|
|
3764
|
+
// `result{ok:true}` here; the host is what decides that a success with no
|
|
3765
|
+
// content-bearing event is a failed turn.
|
|
3766
|
+
name: "zero-content success (empty runtime result)",
|
|
3767
|
+
request: makeRequest(),
|
|
3768
|
+
nativeStream: [init("s1"), resultSuccess("s1")],
|
|
3769
|
+
expected: [sessionEvent("s1"), { type: "result", ok: true }]
|
|
3770
|
+
},
|
|
3717
3771
|
{
|
|
3718
3772
|
// Empty-final: a clean turn that ended on a tool call with no closing text.
|
|
3719
3773
|
// The adapter emits NO final text — empty-final promotion is host/pump
|
|
@@ -4750,6 +4804,18 @@ var OPENCODE_CONFORMANCE_FIXTURES = [
|
|
|
4750
4804
|
],
|
|
4751
4805
|
expected: [sessionEvent2(NEW_SESSION_ID), { type: "result", ok: false, reason: "auth_expired" }]
|
|
4752
4806
|
},
|
|
4807
|
+
{
|
|
4808
|
+
// CT1144: the same zero-content success the claude-code suite pins, in
|
|
4809
|
+
// opencode's vocabulary — the session goes idle having emitted no part at
|
|
4810
|
+
// all. No opencode sample of the anomaly has been seen in the wild; the
|
|
4811
|
+
// fixture exists because the host's classification is runtime-neutral, so a
|
|
4812
|
+
// runtime that CAN produce this envelope must be shown producing it in the
|
|
4813
|
+
// shape the classifier reads.
|
|
4814
|
+
name: "zero-content success (empty runtime result)",
|
|
4815
|
+
request: makeRequest2(),
|
|
4816
|
+
nativeStream: [idle()],
|
|
4817
|
+
expected: [sessionEvent2(NEW_SESSION_ID), { type: "result", ok: true }]
|
|
4818
|
+
},
|
|
4753
4819
|
{
|
|
4754
4820
|
// Empty-final: a clean turn that ended on a tool call with no closing text.
|
|
4755
4821
|
// The adapter emits NO final text — empty-final promotion is host/pump
|
|
@@ -5864,6 +5930,18 @@ var CODEX_CONFORMANCE_FIXTURES = [
|
|
|
5864
5930
|
{ type: "result", ok: true }
|
|
5865
5931
|
]
|
|
5866
5932
|
},
|
|
5933
|
+
{
|
|
5934
|
+
// CT1144: the same zero-content success the claude-code suite pins, in
|
|
5935
|
+
// Codex's vocabulary — the thread starts, the turn completes, and nothing is
|
|
5936
|
+
// emitted in between. No Codex sample of the anomaly has been seen in the
|
|
5937
|
+
// wild; the fixture exists because the host's classification is runtime-
|
|
5938
|
+
// neutral, so a runtime that CAN produce this envelope must be shown
|
|
5939
|
+
// producing it in the shape the classifier reads.
|
|
5940
|
+
name: "zero-content success (empty runtime result)",
|
|
5941
|
+
request: makeRequest3(),
|
|
5942
|
+
nativeStream: [threadStarted(NEW_THREAD_ID), turnCompleted()],
|
|
5943
|
+
expected: [sessionEvent3(NEW_THREAD_ID), { type: "result", ok: true }]
|
|
5944
|
+
},
|
|
5867
5945
|
{
|
|
5868
5946
|
// Empty-final: a clean turn that ended on a tool call with no closing message.
|
|
5869
5947
|
// The adapter emits NO final text — empty-final promotion is host/pump territory
|
|
@@ -7669,6 +7747,7 @@ ${reason}`,
|
|
|
7669
7747
|
result: 0
|
|
7670
7748
|
};
|
|
7671
7749
|
let runtimeResultKind = null;
|
|
7750
|
+
let contentBearingEvents = 0;
|
|
7672
7751
|
let latestSessionState = request.session;
|
|
7673
7752
|
let settledDiagnostics = null;
|
|
7674
7753
|
const committer = new TurnCommitter({
|
|
@@ -7767,6 +7846,7 @@ ${reason}`,
|
|
|
7767
7846
|
for await (const event of adapter.runTurn(request, abortController.signal)) {
|
|
7768
7847
|
transcript?.write(event);
|
|
7769
7848
|
eventCounts[event.type] += 1;
|
|
7849
|
+
if (isContentBearingEvent(event)) contentBearingEvents += 1;
|
|
7770
7850
|
armIdle();
|
|
7771
7851
|
if (abortController.signal.aborted) {
|
|
7772
7852
|
turnLog.info("dispatcher: aborted mid-turn");
|
|
@@ -7827,6 +7907,11 @@ ${reason}`,
|
|
|
7827
7907
|
okResult = false;
|
|
7828
7908
|
resultReason = timeoutReason ?? "cancelled";
|
|
7829
7909
|
}
|
|
7910
|
+
const emptyResultReason = !skipState.skipped && classifyEmptyResult({ ok: okResult, contentBearingEvents, usage: turnUsage });
|
|
7911
|
+
if (emptyResultReason) {
|
|
7912
|
+
okResult = false;
|
|
7913
|
+
resultReason = emptyResultReason;
|
|
7914
|
+
}
|
|
7830
7915
|
if (!okResult && !resultReason) {
|
|
7831
7916
|
resultReason = "no_result";
|
|
7832
7917
|
}
|
|
@@ -7938,9 +8023,7 @@ ${reason}`,
|
|
|
7938
8023
|
this.sessionWriteNotified.add(key);
|
|
7939
8024
|
}
|
|
7940
8025
|
const outcome = skipState.skipped ? "skipped" : userCancelled ? "cancelled" : okResult ? "success" : "failure";
|
|
7941
|
-
const
|
|
7942
|
-
const emptySuccessfulEnvelope = outcome === "success" && eventCounts.result > 0 && noContentEvents;
|
|
7943
|
-
const diagnosticReason = outcome === "skipped" ? { kind: "skipped" } : outcome === "cancelled" ? { kind: "cancelled" } : emptySuccessfulEnvelope ? turnUsage?.inputTokens === 0 && turnUsage.outputTokens === 0 ? { kind: "empty_result" } : { kind: "empty_result_unverified" } : outcome === "failure" ? normalizeTurnResultReason(resultReason) : null;
|
|
8026
|
+
const diagnosticReason = outcome === "skipped" ? { kind: "skipped" } : outcome === "cancelled" ? { kind: "cancelled" } : outcome === "failure" ? normalizeTurnResultReason(resultReason) : null;
|
|
7944
8027
|
settledDiagnostics = {
|
|
7945
8028
|
outcome,
|
|
7946
8029
|
resultReason: diagnosticReason,
|
|
@@ -7956,10 +8039,14 @@ ${reason}`,
|
|
|
7956
8039
|
)) {
|
|
7957
8040
|
turnLog.warn(
|
|
7958
8041
|
{
|
|
8042
|
+
workspaceId,
|
|
7959
8043
|
turnId,
|
|
7960
8044
|
conversationId: payload.conversationId,
|
|
7961
8045
|
agentId: payload.agentId,
|
|
7962
8046
|
runtime: turnRuntime,
|
|
8047
|
+
model: turnResolvedModel ?? null,
|
|
8048
|
+
usage: turnUsage ?? null,
|
|
8049
|
+
hadStoredSession: request.session != null,
|
|
7963
8050
|
diagnostics: settledDiagnostics
|
|
7964
8051
|
},
|
|
7965
8052
|
"dispatcher: anomalous turn settled"
|
|
@@ -8937,6 +9024,10 @@ var CompanionSupervisor = class {
|
|
|
8937
9024
|
if (!agent) {
|
|
8938
9025
|
agent = await this.recoverRacedAgent(wr, payload);
|
|
8939
9026
|
if (!agent) {
|
|
9027
|
+
if (this.deviceId && payload.deviceId === this.deviceId) {
|
|
9028
|
+
const reported = await this.reportUnrunnableDispatch(wr, payload);
|
|
9029
|
+
if (!reported) return;
|
|
9030
|
+
}
|
|
8940
9031
|
if (ev.id) wr.cursor.settle(ev.id);
|
|
8941
9032
|
return;
|
|
8942
9033
|
}
|
|
@@ -8958,6 +9049,59 @@ var CompanionSupervisor = class {
|
|
|
8958
9049
|
await tail;
|
|
8959
9050
|
if (wr.chains.get(chainKey) === tail) wr.chains.delete(chainKey);
|
|
8960
9051
|
}
|
|
9052
|
+
// CT1146: tell the server this device cannot run a dispatch it was addressed to.
|
|
9053
|
+
//
|
|
9054
|
+
// Returns whether the report LANDED, because that answer decides whether the
|
|
9055
|
+
// caller may settle the cursor. Nothing else on either side ends this turn: the
|
|
9056
|
+
// server's sweep resolves the agent's effective connector, which in a roster
|
|
9057
|
+
// desync still points at this device, so it is never classified `no_device` and
|
|
9058
|
+
// only the silent 12h age backstop touches it. An unreported drop is therefore
|
|
9059
|
+
// indistinguishable from the original defect.
|
|
9060
|
+
//
|
|
9061
|
+
// Bounded retry, because the common failure is transient (a 503 mid-deploy, a
|
|
9062
|
+
// dropped socket) and one more attempt a second later usually lands. Bounded
|
|
9063
|
+
// hard — this runs inline on the event loop, and an unbounded wait here would
|
|
9064
|
+
// wedge the workspace's whole stream.
|
|
9065
|
+
async reportUnrunnableDispatch(wr, payload) {
|
|
9066
|
+
if (!this.deviceApi) return false;
|
|
9067
|
+
const backoffMs = [0, 1e3, 3e3];
|
|
9068
|
+
let lastErr;
|
|
9069
|
+
for (const wait of backoffMs) {
|
|
9070
|
+
if (this.stopped) return false;
|
|
9071
|
+
if (wait > 0) await new Promise((r) => setTimeout(r, wait));
|
|
9072
|
+
try {
|
|
9073
|
+
const { cleared } = await this.deviceApi.reportDispatchDrop({
|
|
9074
|
+
workspaceId: wr.workspaceId,
|
|
9075
|
+
conversationId: payload.conversationId,
|
|
9076
|
+
agentId: payload.agentId,
|
|
9077
|
+
messageId: payload.messageId,
|
|
9078
|
+
reason: "agent_not_on_device"
|
|
9079
|
+
});
|
|
9080
|
+
this.log.warn(
|
|
9081
|
+
{
|
|
9082
|
+
workspaceId: wr.workspaceId,
|
|
9083
|
+
conversationId: payload.conversationId,
|
|
9084
|
+
agentId: payload.agentId,
|
|
9085
|
+
cleared
|
|
9086
|
+
},
|
|
9087
|
+
"companion: dispatch addressed to this device is for an agent it does not run \u2014 reported as unrunnable"
|
|
9088
|
+
);
|
|
9089
|
+
return true;
|
|
9090
|
+
} catch (err) {
|
|
9091
|
+
lastErr = err;
|
|
9092
|
+
}
|
|
9093
|
+
}
|
|
9094
|
+
this.log.error(
|
|
9095
|
+
{
|
|
9096
|
+
workspaceId: wr.workspaceId,
|
|
9097
|
+
conversationId: payload.conversationId,
|
|
9098
|
+
agentId: payload.agentId,
|
|
9099
|
+
err: lastErr instanceof Error ? lastErr.message : String(lastErr)
|
|
9100
|
+
},
|
|
9101
|
+
"companion: could not report an unrunnable dispatch \u2014 leaving it unsettled so a reconnect replays it"
|
|
9102
|
+
);
|
|
9103
|
+
return false;
|
|
9104
|
+
}
|
|
8961
9105
|
// Pull-on-miss for the assignment-change race: a dispatch arrived for an agent
|
|
8962
9106
|
// we don't yet run. If it's addressed to THIS device, the assignment just
|
|
8963
9107
|
// changed under us (the poll hasn't caught it) — force a fresh assignments pull
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cabane/companion",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.39",
|
|
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",
|