@hellcoder/companion 0.113.3 → 0.113.4
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/package.json +1 -1
- package/server/claude-adapter.ts +23 -6
- package/server/ws-bridge-types.ts +16 -3
- package/server/ws-bridge.test.ts +121 -2
- package/server/ws-bridge.ts +39 -3
package/package.json
CHANGED
package/server/claude-adapter.ts
CHANGED
|
@@ -496,13 +496,20 @@ export class ClaudeAdapter implements IBackendAdapter {
|
|
|
496
496
|
// a wedge that may be hypothetical.
|
|
497
497
|
const wedgeKillEnabled = getSettings().wedgeKillEnabled !== false;
|
|
498
498
|
if (proc && proc.exitCode === null && !proc.killed && !wedgeKillEnabled) {
|
|
499
|
+
const cliStdoutOpen = stdoutFdOpen(proc.pid);
|
|
499
500
|
log.info("claude-adapter", "stdout closed but wedge-kill is disabled; leaving process alone", {
|
|
500
501
|
sessionId: this.sessionId,
|
|
501
502
|
pid: proc.pid,
|
|
502
|
-
cliStdoutOpen
|
|
503
|
+
cliStdoutOpen,
|
|
503
504
|
proc: captureProcState(proc.pid),
|
|
504
505
|
stderrTail: this.stderrTailForLog(),
|
|
505
506
|
});
|
|
507
|
+
// Emit the reader-side-EOF WARN on this path too. It previously fired
|
|
508
|
+
// only from the two kill branches, so operators running with the
|
|
509
|
+
// wedge-kill switch off — the recommended mitigation — saw these events
|
|
510
|
+
// only as the benign-sounding info line above and had no signal that the
|
|
511
|
+
// EOF originated in the server's own read side.
|
|
512
|
+
this.warnIfReaderSideEof(cliStdoutOpen, proc.pid, false);
|
|
506
513
|
} else if (proc && proc.exitCode === null && !proc.killed) {
|
|
507
514
|
// Which grace applies is decided by whether teardown is actually in
|
|
508
515
|
// progress, NOT by whether the last message happened to be a `result`.
|
|
@@ -654,12 +661,22 @@ export class ClaudeAdapter implements IBackendAdapter {
|
|
|
654
661
|
* the warning attributes the root cause so these events can be counted and
|
|
655
662
|
* the reader-side failure fixed where it lives.
|
|
656
663
|
*/
|
|
657
|
-
private warnIfReaderSideEof(
|
|
664
|
+
private warnIfReaderSideEof(
|
|
665
|
+
cliStdoutOpen: boolean | null,
|
|
666
|
+
pid: number | undefined,
|
|
667
|
+
killing = true,
|
|
668
|
+
): void {
|
|
658
669
|
if (cliStdoutOpen !== true) return;
|
|
659
|
-
log.warn(
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
670
|
+
log.warn(
|
|
671
|
+
"claude-adapter",
|
|
672
|
+
killing
|
|
673
|
+
? "CLI still holds its stdout open — EOF was reader-side (server stream failure), not a CLI wedge; killing anyway so --resume recovery can proceed"
|
|
674
|
+
: "CLI still holds its stdout open — EOF was reader-side (server stream failure), not a CLI wedge; leaving the process alone, but the transport is gone and the session will still relaunch",
|
|
675
|
+
{
|
|
676
|
+
sessionId: this.sessionId,
|
|
677
|
+
pid,
|
|
678
|
+
},
|
|
679
|
+
);
|
|
663
680
|
}
|
|
664
681
|
|
|
665
682
|
private checkSilence(): void {
|
|
@@ -87,10 +87,23 @@ export interface Session {
|
|
|
87
87
|
*/
|
|
88
88
|
turnAwaitingResult?: boolean;
|
|
89
89
|
/**
|
|
90
|
-
*
|
|
91
|
-
* dying cannot be nudged in a loop.
|
|
90
|
+
* Number of mid-answer continuation nudges sent since the turn last produced
|
|
91
|
+
* output, so a turn that keeps dying cannot be nudged in a loop.
|
|
92
|
+
*
|
|
93
|
+
* Deliberately counts *consecutive unproductive* nudges rather than nudges
|
|
94
|
+
* per turn: this was a one-shot boolean, which capped recovery at one nudge
|
|
95
|
+
* for the whole turn. A second stream failure in the same turn — common on a
|
|
96
|
+
* loaded host, where the server's own stdout reader EOFs mid-answer — then had
|
|
97
|
+
* no recovery path at all (`inFlightUserTurn` was already cleared by the first
|
|
98
|
+
* token, so the replay branch is skipped by design), and the session idled
|
|
99
|
+
* until a human typed "continue".
|
|
100
|
+
*
|
|
101
|
+
* Reset to 0 whenever the turn produces output, because a nudge that was
|
|
102
|
+
* followed by output demonstrably worked: the next interruption is a fresh
|
|
103
|
+
* failure, not a loop. Only a nudge that yields nothing counts toward
|
|
104
|
+
* MAX_CONTINUATION_NUDGES. Reset when a new user_message is sent.
|
|
92
105
|
*/
|
|
93
|
-
|
|
106
|
+
continuationAttempts?: number;
|
|
94
107
|
/**
|
|
95
108
|
* Set when the last turn failed authentication (expired/invalid credentials).
|
|
96
109
|
* Auto-relaunch is skipped while this is set — relaunching cannot fix auth —
|
package/server/ws-bridge.test.ts
CHANGED
|
@@ -2120,8 +2120,11 @@ describe("CLI message routing", () => {
|
|
|
2120
2120
|
expect(nudge.content).not.toBe("do a big task");
|
|
2121
2121
|
});
|
|
2122
2122
|
|
|
2123
|
-
it("does not nudge twice
|
|
2124
|
-
// A turn that keeps dying must not accumulate nudges
|
|
2123
|
+
it("does not nudge twice without intervening output", async () => {
|
|
2124
|
+
// A turn that keeps dying with nothing to show must not accumulate nudges:
|
|
2125
|
+
// that shape is indistinguishable from a poison turn that dies on every
|
|
2126
|
+
// resume. The budget only refills once the turn actually produces output
|
|
2127
|
+
// (see the "nudges again after the continued turn produced output" test).
|
|
2125
2128
|
const { session } = await startTurnWithOutput("s1");
|
|
2126
2129
|
const sent: string[] = [];
|
|
2127
2130
|
const attach = () => bridge.attachBackendAdapter("s1", {
|
|
@@ -2169,6 +2172,122 @@ describe("CLI message routing", () => {
|
|
|
2169
2172
|
.filter((m) => m.type === "user_message" && /continue from where/i.test(m.content || ""));
|
|
2170
2173
|
expect(nudges).toHaveLength(0);
|
|
2171
2174
|
});
|
|
2175
|
+
|
|
2176
|
+
/**
|
|
2177
|
+
* The production failure this budget change exists for.
|
|
2178
|
+
*
|
|
2179
|
+
* The nudge guard used to be a one-shot boolean scoped to the whole turn, so
|
|
2180
|
+
* a turn interrupted a SECOND time got no recovery at all: the replay branch
|
|
2181
|
+
* is skipped by design once output has begun (`inFlightUserTurn` is cleared
|
|
2182
|
+
* by the first token), and the boolean blocked the nudge branch. The banner
|
|
2183
|
+
* cleared, the session went idle with a half-written answer, and a human had
|
|
2184
|
+
* to type "continue".
|
|
2185
|
+
*
|
|
2186
|
+
* Observed on companion 0.113.3 (session afdbec35): prompt at 04:20:20 →
|
|
2187
|
+
* stream EOF → nudge at 04:23:32 → the resumed turn streamed for minutes →
|
|
2188
|
+
* stream EOF again → silent reattach → 9m38s dead until a human typed
|
|
2189
|
+
* "continue" at 04:33:12. Both EOFs were reader-side (the CLI still held
|
|
2190
|
+
* fd 1), i.e. the turn was healthy and genuinely worth continuing.
|
|
2191
|
+
*/
|
|
2192
|
+
/**
|
|
2193
|
+
* Attaches a mock adapter the way a relaunch does, capturing both what the
|
|
2194
|
+
* bridge sends and the `onBrowserMessage` handler it registers. Emitting
|
|
2195
|
+
* through that handler is how backend output actually reaches the session
|
|
2196
|
+
* state — `handleCLIMessage` only routes for a real ClaudeAdapter, so it
|
|
2197
|
+
* silently drops once a mock is attached.
|
|
2198
|
+
*/
|
|
2199
|
+
function attachMockAdapter(sessionId: string) {
|
|
2200
|
+
const sent: string[] = [];
|
|
2201
|
+
let handler: ((msg: unknown) => void) | undefined;
|
|
2202
|
+
bridge.attachBackendAdapter(sessionId, {
|
|
2203
|
+
isConnected: () => true,
|
|
2204
|
+
send: (m: unknown) => { sent.push(typeof m === "string" ? m : JSON.stringify(m)); return true; },
|
|
2205
|
+
disconnect: async () => {},
|
|
2206
|
+
onBrowserMessage: (cb: (msg: unknown) => void) => { handler = cb; },
|
|
2207
|
+
onSessionMeta: () => {},
|
|
2208
|
+
onDisconnect: () => {},
|
|
2209
|
+
onInitError: () => {},
|
|
2210
|
+
} as never, "claude");
|
|
2211
|
+
return {
|
|
2212
|
+
sent,
|
|
2213
|
+
emit: (msg: unknown) => handler?.(msg),
|
|
2214
|
+
nudges: () => sent
|
|
2215
|
+
.map((m) => JSON.parse(m))
|
|
2216
|
+
.filter((m) => m.type === "user_message" && /continue from where/i.test(m.content || "")),
|
|
2217
|
+
};
|
|
2218
|
+
}
|
|
2219
|
+
|
|
2220
|
+
it("nudges again after the continued turn produced output", async () => {
|
|
2221
|
+
const { session } = await startTurnWithOutput("s1");
|
|
2222
|
+
|
|
2223
|
+
// First interruption → nudge, and the budget is now spent.
|
|
2224
|
+
const first = attachMockAdapter("s1");
|
|
2225
|
+
expect(first.nudges()).toHaveLength(1);
|
|
2226
|
+
expect(session.continuationAttempts).toBe(1);
|
|
2227
|
+
|
|
2228
|
+
// The resumed turn makes real progress. That proves the nudge worked, so
|
|
2229
|
+
// the budget refills — the next failure is a new one, not a loop.
|
|
2230
|
+
first.emit({
|
|
2231
|
+
type: "stream_event",
|
|
2232
|
+
event: { type: "content_block_delta", delta: { type: "text_delta", text: "more work" } },
|
|
2233
|
+
session_id: "cli-123",
|
|
2234
|
+
uuid: "se-2",
|
|
2235
|
+
});
|
|
2236
|
+
expect(session.continuationAttempts).toBe(0);
|
|
2237
|
+
|
|
2238
|
+
// Second interruption → must nudge again instead of stranding the turn.
|
|
2239
|
+
// Under the old one-shot boolean this produced zero nudges and the session
|
|
2240
|
+
// idled until a human typed "continue".
|
|
2241
|
+
const second = attachMockAdapter("s1");
|
|
2242
|
+
expect(second.nudges()).toHaveLength(1);
|
|
2243
|
+
expect(session.turnAwaitingResult).toBe(true);
|
|
2244
|
+
});
|
|
2245
|
+
|
|
2246
|
+
it("refills the budget on assistant output, not just stream_event", async () => {
|
|
2247
|
+
// Non-streaming output surfaces as an `assistant` message with no
|
|
2248
|
+
// preceding stream_event, so both handlers must refill the budget or those
|
|
2249
|
+
// turns keep the one-nudge-per-turn bug.
|
|
2250
|
+
const { session } = await startTurnWithOutput("s1");
|
|
2251
|
+
|
|
2252
|
+
const first = attachMockAdapter("s1");
|
|
2253
|
+
expect(session.continuationAttempts).toBe(1);
|
|
2254
|
+
|
|
2255
|
+
first.emit({
|
|
2256
|
+
type: "assistant",
|
|
2257
|
+
message: { role: "assistant", content: [{ type: "text", text: "partial answer" }] },
|
|
2258
|
+
session_id: "cli-123",
|
|
2259
|
+
uuid: "a-1",
|
|
2260
|
+
});
|
|
2261
|
+
expect(session.continuationAttempts).toBe(0);
|
|
2262
|
+
|
|
2263
|
+
expect(attachMockAdapter("s1").nudges()).toHaveLength(1);
|
|
2264
|
+
});
|
|
2265
|
+
|
|
2266
|
+
it("stops nudging when the resumed turn dies without producing output", async () => {
|
|
2267
|
+
// The loop protection the budget preserves: repeated nudges that yield
|
|
2268
|
+
// nothing are indistinguishable from a poison turn, so they must stop at
|
|
2269
|
+
// MAX_CONTINUATION_NUDGES (default 1) rather than retry forever.
|
|
2270
|
+
const { session } = await startTurnWithOutput("s1");
|
|
2271
|
+
|
|
2272
|
+
expect(attachMockAdapter("s1").nudges()).toHaveLength(1);
|
|
2273
|
+
expect(attachMockAdapter("s1").nudges()).toHaveLength(0);
|
|
2274
|
+
expect(attachMockAdapter("s1").nudges()).toHaveLength(0);
|
|
2275
|
+
expect(session.continuationAttempts).toBe(1);
|
|
2276
|
+
});
|
|
2277
|
+
|
|
2278
|
+
it("resets the nudge budget when a new user turn starts", async () => {
|
|
2279
|
+
// A fresh prompt is a fresh turn: whatever happened to the previous one
|
|
2280
|
+
// must not eat this turn's recovery.
|
|
2281
|
+
const { browser, session } = await startTurnWithOutput("s1");
|
|
2282
|
+
attachMockAdapter("s1");
|
|
2283
|
+
expect(session.continuationAttempts).toBe(1);
|
|
2284
|
+
|
|
2285
|
+
await bridge.handleBrowserMessage(browser, JSON.stringify({
|
|
2286
|
+
type: "user_message",
|
|
2287
|
+
content: "next question",
|
|
2288
|
+
}));
|
|
2289
|
+
expect(session.continuationAttempts).toBe(0);
|
|
2290
|
+
});
|
|
2172
2291
|
});
|
|
2173
2292
|
|
|
2174
2293
|
it("tool_progress: broadcasts", async () => {
|
package/server/ws-bridge.ts
CHANGED
|
@@ -64,6 +64,23 @@ const RETRYABLE_BACKEND_MESSAGE_TYPES = new Set<BrowserOutgoingMessage["type"]>(
|
|
|
64
64
|
"mcp_set_servers",
|
|
65
65
|
]);
|
|
66
66
|
|
|
67
|
+
/**
|
|
68
|
+
* How many continuation nudges a mid-answer-interrupted turn may get *without
|
|
69
|
+
* producing any output in between*. The counter resets on the turn's next output
|
|
70
|
+
* (see `continuationAttempts`), so this only bounds a turn that dies again
|
|
71
|
+
* having emitted nothing — not a turn that is interrupted repeatedly while
|
|
72
|
+
* making progress.
|
|
73
|
+
*
|
|
74
|
+
* Default 1 keeps the loop protection exactly as strict as the boolean it
|
|
75
|
+
* replaces for the ambiguous case (nudge → died with nothing to show → could be
|
|
76
|
+
* a poison turn), while making the budget *per interruption* instead of per
|
|
77
|
+
* turn. Raise it on hosts where the transport flaps hard enough that a resumed
|
|
78
|
+
* turn sometimes dies before its first token.
|
|
79
|
+
*/
|
|
80
|
+
const MAX_CONTINUATION_NUDGES = Number(
|
|
81
|
+
process.env.COMPANION_MAX_CONTINUATION_NUDGES || "1",
|
|
82
|
+
);
|
|
83
|
+
|
|
67
84
|
export class WsBridge {
|
|
68
85
|
private static readonly PROCESSED_CLIENT_MSG_ID_LIMIT = 1000;
|
|
69
86
|
/** Maximum number of queued browser→backend messages per session to prevent unbounded memory growth. */
|
|
@@ -565,6 +582,10 @@ export class WsBridge {
|
|
|
565
582
|
// The turn is producing output — it was delivered, so drop the in-flight
|
|
566
583
|
// replay copy (no need to re-send it after a future relaunch).
|
|
567
584
|
session.inFlightUserTurn = undefined;
|
|
585
|
+
// Output after a continuation nudge proves the nudge worked, so give the
|
|
586
|
+
// turn a fresh nudge budget: a later interruption is a new failure, not
|
|
587
|
+
// the same one looping. Guarded so the common path writes nothing.
|
|
588
|
+
if (session.continuationAttempts) session.continuationAttempts = 0;
|
|
568
589
|
const assistantMsg = { ...msg, timestamp: msg.timestamp || Date.now() };
|
|
569
590
|
this.appendHistory(session, assistantMsg);
|
|
570
591
|
this.persistSession(session);
|
|
@@ -573,6 +594,10 @@ export class WsBridge {
|
|
|
573
594
|
|
|
574
595
|
if (msg.type === "stream_event") {
|
|
575
596
|
session.inFlightUserTurn = undefined;
|
|
597
|
+
// Same as the assistant branch: progress clears the nudge budget. This is
|
|
598
|
+
// the earliest output signal, so it is what makes a resumed turn that
|
|
599
|
+
// streams for minutes before failing again eligible for another nudge.
|
|
600
|
+
if (session.continuationAttempts) session.continuationAttempts = 0;
|
|
576
601
|
companionBus.emit("message:stream_event", { sessionId: session.id, message: msg });
|
|
577
602
|
}
|
|
578
603
|
|
|
@@ -794,7 +819,7 @@ export class WsBridge {
|
|
|
794
819
|
// relaunch, defeating inFlightTurnReplayed.
|
|
795
820
|
!session.inFlightUserTurn &&
|
|
796
821
|
session.turnAwaitingResult &&
|
|
797
|
-
|
|
822
|
+
(session.continuationAttempts ?? 0) < MAX_CONTINUATION_NUDGES
|
|
798
823
|
) {
|
|
799
824
|
// The turn died AFTER it had begun answering. `--resume` restores the
|
|
800
825
|
// transcript but does not restart the turn, so without this the session
|
|
@@ -803,13 +828,24 @@ export class WsBridge {
|
|
|
803
828
|
// Re-sending the original prompt is wrong here: work already happened and
|
|
804
829
|
// replaying it would redo tool calls. A continuation nudge asks the model
|
|
805
830
|
// to finish from where the transcript stops instead.
|
|
806
|
-
|
|
831
|
+
//
|
|
832
|
+
// Budgeted per *unproductive* nudge, not per turn: the counter resets as
|
|
833
|
+
// soon as the continued turn emits output (see the assistant/stream_event
|
|
834
|
+
// handlers), so a turn interrupted repeatedly still recovers each time
|
|
835
|
+
// while a turn that dies without producing anything stops after
|
|
836
|
+
// MAX_CONTINUATION_NUDGES. Unbounded looping is not a risk here anyway —
|
|
837
|
+
// a nudge can only fire on adapter attach, and attaches are already
|
|
838
|
+
// rate-limited by the orchestrator's MAX_AUTO_RELAUNCHES and
|
|
839
|
+
// MAX_RELAUNCHES_PER_WINDOW caps.
|
|
840
|
+
session.continuationAttempts = (session.continuationAttempts ?? 0) + 1;
|
|
807
841
|
session.pendingMessages.unshift(JSON.stringify({
|
|
808
842
|
type: "user_message",
|
|
809
843
|
content: "Your previous response was interrupted before it finished. Continue from where you left off — do not repeat work that is already in the transcript.",
|
|
810
844
|
}));
|
|
811
845
|
log.info("ws-bridge", "Nudging interrupted turn to continue after relaunch", {
|
|
812
846
|
sessionId,
|
|
847
|
+
attempt: session.continuationAttempts,
|
|
848
|
+
maxAttempts: MAX_CONTINUATION_NUDGES,
|
|
813
849
|
});
|
|
814
850
|
this.broadcastToBrowsers(session, {
|
|
815
851
|
type: "error",
|
|
@@ -1350,7 +1386,7 @@ export class WsBridge {
|
|
|
1350
1386
|
// token: this stays set until the turn actually finishes, so an interrupt
|
|
1351
1387
|
// *mid-answer* is still recognisable as an unfinished turn.
|
|
1352
1388
|
session.turnAwaitingResult = true;
|
|
1353
|
-
session.
|
|
1389
|
+
session.continuationAttempts = 0;
|
|
1354
1390
|
// A fresh turn is an attempt to make progress (e.g. after the user
|
|
1355
1391
|
// re-authenticated), so clear any stale auth-block — a genuine crash on
|
|
1356
1392
|
// this new turn should be allowed to auto-relaunch again.
|