@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 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
- return joined.length > 0 ? truncate(joined, TOOL_IO_MAX_CHARS) : "";
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.trim();
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.trim();
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 noContentEvents = eventCounts.text === 0 && eventCounts.thinking === 0 && eventCounts.tool === 0;
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
- return joined.length > 0 ? truncate(joined, TOOL_IO_MAX_CHARS) : "";
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.trim();
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.trim();
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 noContentEvents = eventCounts.text === 0 && eventCounts.thinking === 0 && eventCounts.tool === 0;
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.37",
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",