Skip to content

Commit c644569

Browse files
committed
fix(#22,#24): 三模块全面审计修复 — 10 个边界问题
conversation-pool.js: - fingerprint 加 system prompt hash 防碰撞 - 非流式路径加 !emulateTools 守卫(与流式一致) - prune 先清过期条目再 LRU - 过期条目计入 misses 修正 hitRate - TTL 注释修正 tool-emulation.js: - 64KB buffer 上限防 OOM(未闭合 JSON 超限时放弃解析当文本吐出) - flush() 用 _findClosingBrace 提取 JSON 而非传整个 remaining 防止尾部文本导致 JSON.parse 失败丢失工具调用 client.js: - warm stall 检查移到 step 循环之后 lastGrowthAt 是当前轮的 修复"最后一轮文本还没读就被 stall 掐断"的截断问题 - cold stall 公式加 toolPreamble 长度 防止工具重请求误判冷启动 - idle 阈值 2→3 + growthSettled 守卫 防止 burst 间隙被误判为结束
1 parent 27a7179 commit c644569

4 files changed

Lines changed: 58 additions & 56 deletions

File tree

‎src/client.js‎

Lines changed: 24 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -358,52 +358,20 @@ export class WindsurfClient {
358358
}
359359
}
360360

361-
// Stall detection — two flavors:
362-
// (a) "cold stall": 30s+ ACTIVE but never saw any text or tool
363-
// call → planner is deadlocked before even starting to
364-
// produce output. Rotate account, don't make the user wait.
365-
// (b) "warm stall": we already streamed some text, but it hasn't
366-
// grown for 15s while status is still non-IDLE → planner is
367-
// stuck in a tool round-trip or upstream throttle. Accept
368-
// what we have as a complete response rather than waiting
369-
// out the full 180s maxWait with the client hanging.
361+
// Cold stall: 30s+ ACTIVE but never saw any text or tool call.
370362
const elapsed = Date.now() - startTime;
371-
// Cap at maxWait (180s): long-context requests can legitimately take
372-
// that long to emit the first token from Cascade. Was 90s which
373-
// still tripped on very long prompts (issue #5).
374-
const coldStallMs = Math.min(maxWait, 30_000 + Math.floor(inputChars / 1500) * 5_000);
363+
const effectiveChars = inputChars + (toolPreamble?.length ?? 0);
364+
const coldStallMs = Math.min(maxWait, 30_000 + Math.floor(effectiveChars / 1500) * 5_000);
375365
if (elapsed > coldStallMs && sawActive && !sawText && seenToolCallIds.size === 0) {
376366
log.warn(`Cascade cold stall: ${elapsed}ms active without any text or tool call (threshold=${coldStallMs}ms, inputChars=${inputChars}), bailing`);
377367
endReason = 'stall_cold';
378368
const err = new Error(`Cascade planner stalled — no output after ${Math.round(coldStallMs / 1000)}s`);
379369
err.isModelError = true;
380370
throw err;
381371
}
382-
if (sawText && lastStatus !== 1 && (Date.now() - lastGrowthAt) > NO_GROWTH_STALL_MS) {
383-
const diag = {
384-
msSinceGrowth: Date.now() - lastGrowthAt,
385-
textLen: totalYielded,
386-
thinkingLen: totalThinking,
387-
stepCount: yieldedByStep.size,
388-
toolCalls: seenToolCallIds.size,
389-
lastStatus,
390-
};
391-
// Short-reply stall → treat as error so handlers/chat.js retries on
392-
// another account. A 50-char preamble is worse than no reply at all
393-
// because the client accepts it as "successful" and shows it to the
394-
// user. Retry only if we haven't streamed anything substantial yet
395-
// (if we did, partial delivery + idle end is fine).
396-
if (totalYielded < STALL_RETRY_MIN_TEXT) {
397-
log.warn('Cascade warm stall (short, retrying on next account)', diag);
398-
endReason = 'stall_warm_retry';
399-
const err = new Error('Cascade planner stalled after preamble — no progress for 25s');
400-
err.isModelError = true;
401-
throw err;
402-
}
403-
log.warn('Cascade warm stall (accepting partial)', diag);
404-
endReason = 'stall_warm';
405-
break; // return what we have as a successful response
406-
}
372+
373+
// NOTE: warm stall check moved AFTER step loop (below) so
374+
// lastGrowthAt reflects data read in this poll, not the previous one.
407375

408376
// Any trajectory change counts as forward progress. A new step, a new
409377
// tool call proposal, or thinking growth all reset the stall timer so
@@ -482,6 +450,22 @@ export class WindsurfClient {
482450
}
483451
}
484452

453+
// Warm stall: text stopped growing for 25s while planner is active.
454+
// Placed AFTER the step loop so lastGrowthAt is current-poll fresh.
455+
if (sawText && lastStatus !== 1 && (Date.now() - lastGrowthAt) > NO_GROWTH_STALL_MS) {
456+
const diag = { msSinceGrowth: Date.now() - lastGrowthAt, textLen: totalYielded, thinkingLen: totalThinking, stepCount: yieldedByStep.size, toolCalls: seenToolCallIds.size, lastStatus };
457+
if (totalYielded < STALL_RETRY_MIN_TEXT) {
458+
log.warn('Cascade warm stall (short, retrying on next account)', diag);
459+
endReason = 'stall_warm_retry';
460+
const err = new Error('Cascade planner stalled after preamble — no progress for 25s');
461+
err.isModelError = true;
462+
throw err;
463+
}
464+
log.warn('Cascade warm stall (accepting partial)', diag);
465+
endReason = 'stall_warm';
466+
break;
467+
}
468+
485469
// Check status
486470
const statusProto = buildGetTrajectoryRequest(cascadeId);
487471
const statusResp = await grpcUnary(
@@ -506,7 +490,8 @@ export class WindsurfClient {
506490
idleCount++;
507491
// Require at least a little text OR a long idle streak before
508492
// accepting "done", so we don't race the first visible chunk.
509-
const canBreak = sawText ? idleCount >= 2 : idleCount >= 4;
493+
const growthSettled = (Date.now() - lastGrowthAt) > pollInterval * 3;
494+
const canBreak = sawText ? (idleCount >= 3 && growthSettled) : idleCount >= 4;
510495
if (canBreak) {
511496
// Final sweep
512497
const finalResp = await grpcUnary(

‎src/conversation-pool.js‎

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
* the same LS and the same account or the cascade_id is meaningless.
1818
* - A checked-out entry is removed from the pool. Concurrent second request
1919
* with the same fingerprint falls back to a fresh cascade.
20-
* - TTL 10 min; LRU eviction at 500 entries.
20+
* - TTL 30 min; LRU eviction at 500 entries.
2121
*/
2222

2323
import { createHash } from 'crypto';
@@ -57,27 +57,31 @@ function canonicalise(messages) {
5757
* may restructure content arrays, add tool_use blocks, or modify text,
5858
* causing hash mismatches and 0% hit rate. (#24)
5959
*/
60+
function systemPrefix(messages) {
61+
return messages
62+
.filter(m => m.role === 'system')
63+
.map(m => typeof m.content === 'string' ? m.content : JSON.stringify(m.content ?? ''))
64+
.join('\0');
65+
}
66+
6067
export function fingerprintBefore(messages, modelKey = '') {
6168
if (!Array.isArray(messages) || messages.length < 2) return null;
6269
const users = messages.filter(m => m.role === 'user');
6370
if (users.length < 2) return null;
64-
return sha256(modelKey + '\0' + JSON.stringify(canonicalise(users.slice(0, -1))));
71+
return sha256(modelKey + '\0' + systemPrefix(messages) + '\0' + JSON.stringify(canonicalise(users.slice(0, -1))));
6572
}
6673

67-
/**
68-
* Fingerprint for the full conversation after this turn completes.
69-
* Uses all user messages (including current). The *next* request's
70-
* `fingerprintBefore` will hash users[:-1] which equals this value.
71-
*/
7274
export function fingerprintAfter(messages, modelKey = '') {
7375
const users = messages.filter(m => m.role === 'user');
7476
if (!users.length) return null;
75-
return sha256(modelKey + '\0' + JSON.stringify(canonicalise(users)));
77+
return sha256(modelKey + '\0' + systemPrefix(messages) + '\0' + JSON.stringify(canonicalise(users)));
7678
}
7779

7880
function prune(now) {
81+
for (const [fp, e] of _pool) {
82+
if (now - e.lastAccess > POOL_TTL_MS) { _pool.delete(fp); stats.expired++; }
83+
}
7984
if (_pool.size <= POOL_MAX) return;
80-
// Drop oldest entries until back under the cap.
8185
const entries = [..._pool.entries()].sort((a, b) => a[1].lastAccess - b[1].lastAccess);
8286
const toDrop = entries.length - POOL_MAX;
8387
for (let i = 0; i < toDrop; i++) {
@@ -100,6 +104,7 @@ export function checkout(fingerprint) {
100104
_pool.delete(fingerprint);
101105
if (Date.now() - entry.lastAccess > POOL_TTL_MS) {
102106
stats.expired++;
107+
stats.misses++;
103108
return null;
104109
}
105110
stats.hits++;

‎src/handlers/chat.js‎

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -257,10 +257,7 @@ export async function handleChatCompletions(body) {
257257
// instead of replaying the whole history.
258258
//
259259
// Conversation reuse lets Cascade keep server-side context across turns.
260-
// Previously disabled for tool-emulation but that caused ALL Claude Code
261-
// conversations to lose context after every turn (#24). A fingerprint miss
262-
// just falls back to fresh cascade (no worse than before). (#24)
263-
const reuseEnabled = useCascade && isExperimentalEnabled('cascadeConversationReuse');
260+
const reuseEnabled = useCascade && !emulateTools && isExperimentalEnabled('cascadeConversationReuse');
264261
const fpBefore = reuseEnabled ? fingerprintBefore(messages, modelKey) : null;
265262
let reuseEntry = reuseEnabled ? poolCheckout(fpBefore) : null;
266263
if (reuseEntry) log.info(`Chat: cascade reuse HIT cascadeId=${reuseEntry.cascadeId.slice(0, 8)}… model=${displayModel}`);

‎src/handlers/tool-emulation.js‎

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,11 @@ export class ToolCallStreamParser {
249249
}
250250

251251
_consumeJsonBlock(parseFn, doneCalls, safeParts) {
252+
if (this.buffer.length > 65_536) {
253+
safeParts.push(this.buffer);
254+
this.buffer = '';
255+
return true;
256+
}
252257
const endIdx = this._findClosingBrace();
253258
if (endIdx === -1) return false;
254259
const jsonStr = this.buffer.slice(0, endIdx + 1);
@@ -426,14 +431,24 @@ export class ToolCallStreamParser {
426431
}
427432
if (this.inToolCode) {
428433
this.inToolCode = false;
429-
const tc = this._parseToolCodeJson(remaining);
430-
if (tc) { this._totalSeen++; return { text: '', toolCalls: [tc] }; }
434+
const endIdx = this._findClosingBrace();
435+
if (endIdx !== -1) {
436+
const jsonStr = remaining.slice(0, endIdx + 1);
437+
const tail = remaining.slice(endIdx + 1);
438+
const tc = this._parseToolCodeJson(jsonStr);
439+
if (tc) { this._totalSeen++; return { text: tail, toolCalls: [tc] }; }
440+
}
431441
return { text: remaining, toolCalls: [] };
432442
}
433443
if (this.inBareCall) {
434444
this.inBareCall = false;
435-
const tc = this._parseBareToolCallJson(remaining);
436-
if (tc) { this._totalSeen++; return { text: '', toolCalls: [tc] }; }
445+
const endIdx = this._findClosingBrace();
446+
if (endIdx !== -1) {
447+
const jsonStr = remaining.slice(0, endIdx + 1);
448+
const tail = remaining.slice(endIdx + 1);
449+
const tc = this._parseBareToolCallJson(jsonStr);
450+
if (tc) { this._totalSeen++; return { text: tail, toolCalls: [tc] }; }
451+
}
437452
return { text: remaining, toolCalls: [] };
438453
}
439454
// Fallback: detect any remaining tool_code patterns in leftover buffer

0 commit comments

Comments
 (0)