@0xmaxma/claude-gateway 1.3.25 → 1.3.32
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/README.md +50 -2
- package/config.template.json +6 -2
- package/dist/agent/incident-store.d.ts +89 -0
- package/dist/agent/incident-store.d.ts.map +1 -0
- package/dist/agent/incident-store.js +299 -0
- package/dist/agent/incident-store.js.map +1 -0
- package/dist/agent/incident.d.ts +156 -0
- package/dist/agent/incident.d.ts.map +1 -0
- package/dist/agent/incident.js +177 -0
- package/dist/agent/incident.js.map +1 -0
- package/dist/agent/recovery-executor.d.ts +117 -0
- package/dist/agent/recovery-executor.d.ts.map +1 -0
- package/dist/agent/recovery-executor.js +168 -0
- package/dist/agent/recovery-executor.js.map +1 -0
- package/dist/agent/recovery-policy.d.ts +97 -0
- package/dist/agent/recovery-policy.d.ts.map +1 -0
- package/dist/agent/recovery-policy.js +164 -0
- package/dist/agent/recovery-policy.js.map +1 -0
- package/dist/agent/runner.d.ts +66 -6
- package/dist/agent/runner.d.ts.map +1 -1
- package/dist/agent/runner.js +332 -27
- package/dist/agent/runner.js.map +1 -1
- package/dist/agent/safe-mode.d.ts +61 -0
- package/dist/agent/safe-mode.d.ts.map +1 -0
- package/dist/agent/safe-mode.js +102 -0
- package/dist/agent/safe-mode.js.map +1 -0
- package/dist/agent/triage.d.ts +94 -0
- package/dist/agent/triage.d.ts.map +1 -0
- package/dist/agent/triage.js +209 -0
- package/dist/agent/triage.js.map +1 -0
- package/dist/agent/turn-trace.d.ts +120 -0
- package/dist/agent/turn-trace.d.ts.map +1 -0
- package/dist/agent/turn-trace.js +122 -0
- package/dist/agent/turn-trace.js.map +1 -0
- package/dist/api/gateway-router.d.ts +21 -0
- package/dist/api/gateway-router.d.ts.map +1 -1
- package/dist/api/gateway-router.js +58 -17
- package/dist/api/gateway-router.js.map +1 -1
- package/dist/api/router.d.ts.map +1 -1
- package/dist/api/router.js +104 -4
- package/dist/api/router.js.map +1 -1
- package/dist/config/migrator.d.ts +4 -0
- package/dist/config/migrator.d.ts.map +1 -1
- package/dist/config/migrator.js +60 -3
- package/dist/config/migrator.js.map +1 -1
- package/dist/history/db.d.ts +13 -1
- package/dist/history/db.d.ts.map +1 -1
- package/dist/history/db.js +67 -9
- package/dist/history/db.js.map +1 -1
- package/dist/history/types.d.ts +10 -0
- package/dist/history/types.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/session/process.d.ts +26 -0
- package/dist/session/process.d.ts.map +1 -1
- package/dist/session/process.js +77 -1
- package/dist/session/process.js.map +1 -1
- package/dist/shell/claude-pty-shell.js +59 -0
- package/dist/shell/claude-pty-shell.js.map +1 -1
- package/dist/shell/control-channel.d.ts +74 -0
- package/dist/shell/control-channel.d.ts.map +1 -0
- package/dist/shell/control-channel.js +114 -0
- package/dist/shell/control-channel.js.map +1 -0
- package/dist/types.d.ts +22 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/ui/web-ui.d.ts.map +1 -1
- package/dist/ui/web-ui.js +103 -2
- package/dist/ui/web-ui.js.map +1 -1
- package/mcp/tools/skills/handlers.ts +4 -2
- package/mcp/tools/telegram/receiver-server.ts +163 -0
- package/mcp/tools/telegram/typing.ts +124 -0
- package/package.json +3 -1
package/dist/agent/runner.js
CHANGED
|
@@ -49,6 +49,11 @@ const line_reply_manager_1 = require("./line-reply-manager");
|
|
|
49
49
|
const markdown_1 = require("../telegram/markdown");
|
|
50
50
|
const skills_1 = require("../skills");
|
|
51
51
|
const builtin_commands_1 = require("./builtin-commands");
|
|
52
|
+
const safe_mode_1 = require("./safe-mode");
|
|
53
|
+
const recovery_executor_1 = require("./recovery-executor");
|
|
54
|
+
const recovery_policy_1 = require("./recovery-policy");
|
|
55
|
+
const incident_1 = require("./incident");
|
|
56
|
+
const pty_stream_registry_1 = require("../shell/pty-stream-registry");
|
|
52
57
|
const screen_1 = require("../shell/screen");
|
|
53
58
|
const db_1 = require("../history/db");
|
|
54
59
|
const media_store_1 = require("../history/media-store");
|
|
@@ -56,14 +61,16 @@ const cleanup_1 = require("../history/cleanup");
|
|
|
56
61
|
const DEFAULT_IDLE_TIMEOUT_MINUTES = 30;
|
|
57
62
|
const DEFAULT_MAX_CONCURRENT = 20;
|
|
58
63
|
const ANTHROPIC_SOCKET_ERROR = 'socket connection was closed unexpectedly';
|
|
59
|
-
// History re-injection ladder for request_too_large (32MB) recovery
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
// /clear instead of looping forever.
|
|
64
|
+
// History re-injection ladder for request_too_large (32MB) recovery: the
|
|
65
|
+
// candidate history sizes a recovering session can step down to. The HEALTHY
|
|
66
|
+
// spawn uses the configured cap (resolveMaxHistoryMessages), not an index into
|
|
67
|
+
// this array; on each consecutive 32MB recovery the session drops to the next
|
|
68
|
+
// ladder rung STRICTLY BELOW that cap (see spawnHistoryLimit), so every retry
|
|
69
|
+
// actually shrinks the re-loaded context instead of re-trying the same size.
|
|
70
|
+
// Once even the 0-history rung still trips 32MB the runner stops escalating and
|
|
71
|
+
// asks the user to /clear instead of looping forever. The leading
|
|
72
|
+
// MAX_HISTORY_MESSAGES only acts as a recovery rung when an operator configures
|
|
73
|
+
// a cap higher than it.
|
|
67
74
|
const TOO_LARGE_HISTORY_LADDER = [process_1.MAX_HISTORY_MESSAGES, 40, 30, 20, 10, 0];
|
|
68
75
|
exports.MAX_IMAGE_SIZE_BYTES = 10 * 1024 * 1024;
|
|
69
76
|
exports.DEFAULT_MODELS = [
|
|
@@ -82,6 +89,14 @@ const PROTECTED_WORKSPACE_FILES = [
|
|
|
82
89
|
'IDENTITY.md', 'USER.md', 'HEARTBEAT.md',
|
|
83
90
|
];
|
|
84
91
|
const MAX_API_IMAGES = 5;
|
|
92
|
+
// Hard timeout for the one-shot local `claude -p` triage during recovery
|
|
93
|
+
// (Epic #195, Phase 3b). A slow/hung triage collapses to a safe notify-only.
|
|
94
|
+
const RECOVERY_TRIAGE_TIMEOUT_MS = 15000;
|
|
95
|
+
// TTL after which a per-turn recovery budget entry is evicted. A turn's budget
|
|
96
|
+
// only matters during its active stall window (a few interventions spaced by a
|
|
97
|
+
// 30s cooldown), so anything older is a finished turn — pruned to keep the
|
|
98
|
+
// budget map from growing unbounded over a long-lived runner (Epic #195, 3b).
|
|
99
|
+
const RECOVERY_BUDGET_TTL_MS = 10 * 60000;
|
|
85
100
|
// Trailing-edge window for coalescing channel messages that carry an image (or that
|
|
86
101
|
// arrive while an image is already buffered) into a single turn. Reset on every new
|
|
87
102
|
// related update, so an album burst or a client-split photo+caption is gathered
|
|
@@ -157,6 +172,27 @@ class AgentRunner extends events_1.EventEmitter {
|
|
|
157
172
|
// restarting would just churn a context that cannot shrink. Cleared together
|
|
158
173
|
// with the counter on a successful result and on /clear.
|
|
159
174
|
this.tooLargeExhausted = new Set();
|
|
175
|
+
// Safe-mode manager (Epic #195, Phase 3): tracks repeated PTY-backend failures
|
|
176
|
+
// and, past a threshold, forces this agent to the headless backend so it keeps
|
|
177
|
+
// serving turns without a gateway restart. In-memory only — a restart clears it
|
|
178
|
+
// and re-reads the user's real config. spawnSession reads isActive() to set
|
|
179
|
+
// SessionProcess.forceHeadless.
|
|
180
|
+
this.safeMode = new safe_mode_1.SafeModeManager({
|
|
181
|
+
audit: (e) => this.logger.info('Safe-mode transition', {
|
|
182
|
+
agentId: e.agentId,
|
|
183
|
+
action: e.action,
|
|
184
|
+
reason: e.reason,
|
|
185
|
+
failures: e.failures,
|
|
186
|
+
}),
|
|
187
|
+
});
|
|
188
|
+
// Recovery executor state (Epic #195, Phase 3b). Per-turn intervention budget,
|
|
189
|
+
// keyed by turnKey so it resets each turn. In-memory only.
|
|
190
|
+
this.recoveryBudgets = new Map();
|
|
191
|
+
// Last injected turn per chat, for the C1 guarded resend: `delivered` flips
|
|
192
|
+
// true once assistant output reaches the channel, so a resend after recovery
|
|
193
|
+
// only fires when the stalled turn produced nothing (no double-submit). `resent`
|
|
194
|
+
// guards against resending more than once per turn.
|
|
195
|
+
this.lastTurn = new Map();
|
|
160
196
|
// Tracks pending Telegram image paths per chatId (queue) for size accumulation after each turn.
|
|
161
197
|
this.pendingImagePaths = new Map();
|
|
162
198
|
// Coalescing buffer: collects channel messages that arrive close together so a
|
|
@@ -177,6 +213,46 @@ class AgentRunner extends events_1.EventEmitter {
|
|
|
177
213
|
this.skillRegistry = { skills: new Map() };
|
|
178
214
|
// Cancel function for the daily history cleanup timer
|
|
179
215
|
this.cancelCleanup = null;
|
|
216
|
+
/**
|
|
217
|
+
* One-shot local `claude -p` triage runner (Phase 3b). Feeds the closed
|
|
218
|
+
* classification prompt on stdin and returns raw stdout. Hard timeout →
|
|
219
|
+
* timedOut, which the executor collapses to a safe notify-only verdict. Only
|
|
220
|
+
* invoked when autoRecover is enabled.
|
|
221
|
+
*/
|
|
222
|
+
this.recoveryTriageSpawn = async (prompt) => {
|
|
223
|
+
const { spawn } = await Promise.resolve().then(() => __importStar(require('child_process')));
|
|
224
|
+
return new Promise((resolve) => {
|
|
225
|
+
let done = false;
|
|
226
|
+
let out = '';
|
|
227
|
+
const finish = (r) => {
|
|
228
|
+
if (done)
|
|
229
|
+
return;
|
|
230
|
+
done = true;
|
|
231
|
+
clearTimeout(timer);
|
|
232
|
+
resolve(r);
|
|
233
|
+
};
|
|
234
|
+
const timer = setTimeout(() => {
|
|
235
|
+
try {
|
|
236
|
+
child.kill('SIGKILL');
|
|
237
|
+
}
|
|
238
|
+
catch { /* already gone */ }
|
|
239
|
+
finish({ stdout: '', timedOut: true });
|
|
240
|
+
}, RECOVERY_TRIAGE_TIMEOUT_MS);
|
|
241
|
+
const child = spawn('claude', ['-p', '--output-format', 'text'], {
|
|
242
|
+
stdio: ['pipe', 'pipe', 'ignore'],
|
|
243
|
+
});
|
|
244
|
+
child.stdout?.on('data', (d) => { out += String(d); });
|
|
245
|
+
child.on('error', () => finish({ stdout: '', timedOut: true }));
|
|
246
|
+
child.on('close', () => finish({ stdout: out }));
|
|
247
|
+
try {
|
|
248
|
+
child.stdin?.write(prompt);
|
|
249
|
+
child.stdin?.end();
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
finish({ stdout: '', timedOut: true });
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
};
|
|
180
256
|
this.agentConfig = agentConfig;
|
|
181
257
|
this.gatewayConfig = gatewayConfig;
|
|
182
258
|
this.logger = logger ?? (0, logger_1.createLogger)(agentConfig.id, gatewayConfig.gateway.logDir);
|
|
@@ -229,6 +305,13 @@ class AgentRunner extends events_1.EventEmitter {
|
|
|
229
305
|
this.handleCommandRequest(raw, res);
|
|
230
306
|
return;
|
|
231
307
|
}
|
|
308
|
+
if (url.pathname === '/recover') {
|
|
309
|
+
// Cross-process recovery bridge (Epic #195, Phase 3b): the receiver
|
|
310
|
+
// process detects a stall but recovery must run here, where the live
|
|
311
|
+
// control surfaces (session stdin, restart, safe-mode) live.
|
|
312
|
+
this.handleRecoverRequest(raw, res);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
232
315
|
// Default: /channel — existing channel message handler
|
|
233
316
|
// Connection: close prevents keep-alive pool reuse: after the OS's
|
|
234
317
|
// keepAliveTimeout expires the server closes the TCP connection, and the
|
|
@@ -315,6 +398,160 @@ class AgentRunner extends events_1.EventEmitter {
|
|
|
315
398
|
});
|
|
316
399
|
this.logger.info('Channel callback server listening', { port: this.callbackPort });
|
|
317
400
|
}
|
|
401
|
+
/**
|
|
402
|
+
* Handle POST /recover from the receiver process (Epic #195, Phase 3b).
|
|
403
|
+
* The watchdog detects a stall in the receiver, but recovery must run here in
|
|
404
|
+
* the runner, where the live control surfaces live. Runs the pure executor
|
|
405
|
+
* with effects bound to the affected session and returns the outcome so the
|
|
406
|
+
* receiver can persist it to the incident bundle.
|
|
407
|
+
*/
|
|
408
|
+
async handleRecoverRequest(raw, res) {
|
|
409
|
+
const respond = (data, status = 200) => {
|
|
410
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
411
|
+
res.end(JSON.stringify(data));
|
|
412
|
+
};
|
|
413
|
+
let body;
|
|
414
|
+
try {
|
|
415
|
+
body = JSON.parse(raw);
|
|
416
|
+
}
|
|
417
|
+
catch {
|
|
418
|
+
respond({ ok: false, error: 'Invalid JSON' }, 400);
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
const chatId = body.chatId ?? '';
|
|
422
|
+
const stage = body.stage ?? '';
|
|
423
|
+
if (!chatId || !stage) {
|
|
424
|
+
respond({ ok: false, error: 'missing chatId/stage' }, 400);
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
const session = this.sessions.get(chatId);
|
|
428
|
+
const autoRecover = this.gatewayConfig.gateway.selfHealing?.autoRecover === true;
|
|
429
|
+
const req = {
|
|
430
|
+
incidentId: body.incidentId ?? '',
|
|
431
|
+
agentId: this.agentConfig.id,
|
|
432
|
+
chatId,
|
|
433
|
+
// Prefer the live session's id (survives a request that names a stale one).
|
|
434
|
+
sessionId: session?.sessionId ?? body.sessionId ?? '',
|
|
435
|
+
stage,
|
|
436
|
+
failureClass: body.failureClass ?? null,
|
|
437
|
+
turnKey: body.turnKey ?? chatId,
|
|
438
|
+
};
|
|
439
|
+
try {
|
|
440
|
+
const result = await (0, recovery_executor_1.runRecovery)(req, {
|
|
441
|
+
autoRecover,
|
|
442
|
+
effects: this.buildRecoveryEffects(chatId),
|
|
443
|
+
now: () => Date.now(),
|
|
444
|
+
budget: {
|
|
445
|
+
get: (turnKey) => this.recoveryBudgets.get(turnKey) ?? (0, recovery_policy_1.initialBudget)(turnKey),
|
|
446
|
+
set: (s) => {
|
|
447
|
+
this.recoveryBudgets.set(s.turnKey, s);
|
|
448
|
+
this.pruneRecoveryBudgets(s.lastAt);
|
|
449
|
+
},
|
|
450
|
+
},
|
|
451
|
+
// Live triage is the most sensitive surface — only ever run when the
|
|
452
|
+
// operator has opted in. When off, the executor uses the deterministic
|
|
453
|
+
// per-stage default action (still whitelist-clamped).
|
|
454
|
+
triageSpawn: autoRecover ? this.recoveryTriageSpawn : undefined,
|
|
455
|
+
gatherEvidence: async () => {
|
|
456
|
+
const s = this.sessions.get(chatId);
|
|
457
|
+
if (!s)
|
|
458
|
+
return null;
|
|
459
|
+
try {
|
|
460
|
+
const snap = await pty_stream_registry_1.ptyStreamRegistry.screenText(s.sessionId);
|
|
461
|
+
if (!snap)
|
|
462
|
+
return null;
|
|
463
|
+
// Scrub the untrusted screen before it leaves the process / reaches
|
|
464
|
+
// the triage model. Redact the chat id explicitly on top of the
|
|
465
|
+
// pattern-based secret scrub.
|
|
466
|
+
return { screenText: (0, incident_1.scrubText)(snap.text, [chatId]) };
|
|
467
|
+
}
|
|
468
|
+
catch {
|
|
469
|
+
return null;
|
|
470
|
+
}
|
|
471
|
+
},
|
|
472
|
+
resendAfterRecover: autoRecover,
|
|
473
|
+
log: (msg, meta) => this.logger.info(msg, meta),
|
|
474
|
+
});
|
|
475
|
+
respond({ ok: true, outcome: (0, recovery_executor_1.toRecoveryOutcome)(result), result });
|
|
476
|
+
}
|
|
477
|
+
catch (err) {
|
|
478
|
+
this.logger.error('Recovery attempt failed', { chatId, stage, error: err.message });
|
|
479
|
+
respond({ ok: false, error: err.message }, 500);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* Route raw interactive-terminal input to the session with this actual
|
|
484
|
+
* sessionId (Issue #201). The dashboard's Terminal Viewer input mode
|
|
485
|
+
* streams keystrokes here via the pty-stream WebSocket. Sessions are keyed by
|
|
486
|
+
* chatId internally, so we match on the process's own sessionId. Returns true
|
|
487
|
+
* only when a live pty-shell session accepted the bytes (headless / missing /
|
|
488
|
+
* not-writable → false), so the caller can surface an accurate result.
|
|
489
|
+
*/
|
|
490
|
+
sendInputToSession(sessionId, data) {
|
|
491
|
+
if (!sessionId || typeof data !== 'string' || data.length === 0)
|
|
492
|
+
return false;
|
|
493
|
+
for (const proc of this.sessions.values()) {
|
|
494
|
+
if (proc.sessionId === sessionId) {
|
|
495
|
+
return proc.sendInput(data);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
return false;
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* Build the recovery effects bound to one chat's session (Phase 3b). Each
|
|
502
|
+
* effect resolves the session fresh so it survives a restart mid-recovery.
|
|
503
|
+
* Keystroke effects go through the wrapper control channel; restart/backend
|
|
504
|
+
* effects act on the session and safe-mode manager. Effects intentionally
|
|
505
|
+
* omitted (redeliver-forward, restart-receiver, bridge-menu) are transport /
|
|
506
|
+
* wrapper concerns not bridged to the runner — the executor reports them as
|
|
507
|
+
* unsupported rather than guessing.
|
|
508
|
+
*/
|
|
509
|
+
buildRecoveryEffects(chatId) {
|
|
510
|
+
const control = (key, option) => {
|
|
511
|
+
this.sessions.get(chatId)?.sendControl(key, option);
|
|
512
|
+
};
|
|
513
|
+
return {
|
|
514
|
+
esc: () => control('esc'),
|
|
515
|
+
escEsc: () => control('esc-esc'),
|
|
516
|
+
enter: () => control('enter'),
|
|
517
|
+
selectOption: (option) => control('select-option', option),
|
|
518
|
+
restartSession: () => this.restartProcess(chatId),
|
|
519
|
+
fallbackHeadless: async () => {
|
|
520
|
+
// Flip to the headless backend, then restart so the new session respawns
|
|
521
|
+
// headless (spawnSession reads safeMode.isActive → forceHeadless).
|
|
522
|
+
this.safeMode.enter(this.agentConfig.id, 'recovery: fallback-headless');
|
|
523
|
+
await this.restartProcess(chatId);
|
|
524
|
+
},
|
|
525
|
+
resendLast: () => {
|
|
526
|
+
const lt = this.lastTurn.get(chatId);
|
|
527
|
+
// Guard: only resend a turn that produced no output and was not already
|
|
528
|
+
// resent — never double-submit.
|
|
529
|
+
if (!lt || lt.delivered || lt.resent)
|
|
530
|
+
return false;
|
|
531
|
+
const s = this.sessions.get(chatId);
|
|
532
|
+
if (!s)
|
|
533
|
+
return false;
|
|
534
|
+
lt.resent = true;
|
|
535
|
+
s.setProcessing(true);
|
|
536
|
+
s.sendMessage(lt.text);
|
|
537
|
+
s.touch();
|
|
538
|
+
return true;
|
|
539
|
+
},
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* Evict finished-turn budget entries (Epic #195, Phase 3b). Called on each
|
|
544
|
+
* budget write so the per-turn map cannot grow without bound on a long-lived
|
|
545
|
+
* runner. `nowMs` is the timestamp of the write just made; any entry whose last
|
|
546
|
+
* attempt predates the TTL belongs to a turn that is no longer being recovered.
|
|
547
|
+
*/
|
|
548
|
+
pruneRecoveryBudgets(nowMs) {
|
|
549
|
+
const cutoff = nowMs - RECOVERY_BUDGET_TTL_MS;
|
|
550
|
+
for (const [key, state] of this.recoveryBudgets) {
|
|
551
|
+
if (state.lastAt < cutoff)
|
|
552
|
+
this.recoveryBudgets.delete(key);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
318
555
|
/**
|
|
319
556
|
* Handle POST /command requests from the receiver process.
|
|
320
557
|
* Supports: get_model, set_model, restart.
|
|
@@ -599,7 +836,12 @@ class AgentRunner extends events_1.EventEmitter {
|
|
|
599
836
|
}
|
|
600
837
|
}
|
|
601
838
|
session.setProcessing(true);
|
|
602
|
-
|
|
839
|
+
const turnText = blocks.join('\n');
|
|
840
|
+
session.sendMessage(turnText);
|
|
841
|
+
// Remember this turn for the C1 guarded resend (Phase 3b): reset the
|
|
842
|
+
// delivered/resent flags so a resend can only fire if this turn stalls
|
|
843
|
+
// before producing output.
|
|
844
|
+
this.lastTurn.set(chatId, { text: turnText, delivered: false, resent: false });
|
|
603
845
|
session.touch();
|
|
604
846
|
this.logger.debug('Injected channel turn into session', {
|
|
605
847
|
chatId,
|
|
@@ -716,19 +958,32 @@ class AgentRunner extends events_1.EventEmitter {
|
|
|
716
958
|
// Apply per-session model override (caller already normalized to undefined when == agent default)
|
|
717
959
|
if (modelOverride)
|
|
718
960
|
proc.modelOverride = modelOverride;
|
|
961
|
+
// Per-agent → global → MAX_HISTORY_MESSAGES: the configured cap on how many
|
|
962
|
+
// history messages a healthy spawn re-injects. Lets an operator lower the
|
|
963
|
+
// context loaded at session start (e.g. 50 → 30) without touching code.
|
|
964
|
+
const configuredMax = (0, process_1.resolveMaxHistoryMessages)(this.agentConfig.history?.maxHistoryMessages, this.gatewayConfig.gateway.history?.maxHistoryMessages);
|
|
719
965
|
// request_too_large (32MB) recovery: shrink the re-injected history on each
|
|
720
966
|
// consecutive retry so a pathological context eventually fits. recoveryCount
|
|
721
|
-
// is 0 for healthy sessions →
|
|
722
|
-
//
|
|
723
|
-
//
|
|
967
|
+
// is 0 for healthy sessions → use the configured cap directly; a recovering
|
|
968
|
+
// session steps down to the ladder rungs STRICTLY BELOW that cap, so each
|
|
969
|
+
// retry genuinely shrinks instead of re-trying the same (already-too-large)
|
|
970
|
+
// size when the cap has been lowered.
|
|
724
971
|
const recoveryCount = this.tooLargeRecoveries.get(mapKey) ?? 0;
|
|
725
|
-
|
|
726
|
-
proc.historyLimit = TOO_LARGE_HISTORY_LADDER[ladderIdx];
|
|
972
|
+
proc.historyLimit = this.spawnHistoryLimit(configuredMax, recoveryCount);
|
|
727
973
|
if (recoveryCount > 0) {
|
|
728
974
|
this.logger.info('Spawning with reduced history after request_too_large', {
|
|
729
975
|
mapKey, recoveryCount, historyLimit: proc.historyLimit,
|
|
730
976
|
});
|
|
731
977
|
}
|
|
978
|
+
// Safe mode (Epic #195, Phase 3): if the PTY backend has repeatedly failed
|
|
979
|
+
// for this agent, force the headless backend for this spawn. Cleared
|
|
980
|
+
// automatically once safe mode exits (a later healthy turn / user restore).
|
|
981
|
+
if (this.safeMode.isActive(this.agentConfig.id)) {
|
|
982
|
+
proc.forceHeadless = true;
|
|
983
|
+
this.logger.info('Spawning in safe mode (headless backend forced)', {
|
|
984
|
+
mapKey, agentId: this.agentConfig.id,
|
|
985
|
+
});
|
|
986
|
+
}
|
|
732
987
|
await proc.start();
|
|
733
988
|
// Forward all session output lines so listeners on AgentRunner (GatewayRouter,
|
|
734
989
|
// CronScheduler, tests) receive them without needing individual session references.
|
|
@@ -736,7 +991,16 @@ class AgentRunner extends events_1.EventEmitter {
|
|
|
736
991
|
// Notify typing indicator when session permanently fails (max restarts exceeded)
|
|
737
992
|
if (source !== 'api') {
|
|
738
993
|
proc.once('failed', () => {
|
|
739
|
-
|
|
994
|
+
// Safe mode (Epic #195, Phase 3): a hard failure of the PTY (interactive
|
|
995
|
+
// TUI) backend counts toward the safe-mode threshold. Once crossed, the
|
|
996
|
+
// agent auto-flips to headless on the next spawn so the user's next
|
|
997
|
+
// message is served instead of re-wedging. Headless failures don't count
|
|
998
|
+
// (there is no PTY wrapper to blame / fall back from). When we just
|
|
999
|
+
// entered safe mode, tell the user that specifically instead of the
|
|
1000
|
+
// generic "stopped" notice (one .error file, so pick the better code).
|
|
1001
|
+
const enteredSafeMode = proc.backend === 'pty-shell' &&
|
|
1002
|
+
this.safeMode.recordPtyFailure(this.agentConfig.id);
|
|
1003
|
+
this.writeTypingError(mapKey, enteredSafeMode ? 'SAFE_MODE_ENABLED' : 'PROCESS_FAILED');
|
|
740
1004
|
this.sessions.delete(mapKey);
|
|
741
1005
|
// LINE: surface an error for any outstanding postback button so a tap
|
|
742
1006
|
// returns the interrupted notice instead of a stale "still thinking".
|
|
@@ -899,6 +1163,13 @@ class AgentRunner extends events_1.EventEmitter {
|
|
|
899
1163
|
// the next 32MB (if any) starts fresh at the top of the ladder.
|
|
900
1164
|
this.tooLargeRecoveries.delete(mapKey);
|
|
901
1165
|
this.tooLargeExhausted.delete(mapKey);
|
|
1166
|
+
// Safe mode (Epic #195, Phase 3): a healthy PTY turn resets the
|
|
1167
|
+
// consecutive-failure counter and lifts safe mode if it was active
|
|
1168
|
+
// (the interactive backend recovered). Headless successes don't
|
|
1169
|
+
// touch it — that's the fallback doing its job, not the PTY healing.
|
|
1170
|
+
if (proc.backend === 'pty-shell') {
|
|
1171
|
+
this.safeMode.recordSuccess(this.agentConfig.id);
|
|
1172
|
+
}
|
|
902
1173
|
}
|
|
903
1174
|
// When a menu was rendered to buttons this turn, the wrapper appends
|
|
904
1175
|
// the same menu text to the turn's result — strip that suffix so it
|
|
@@ -941,6 +1212,11 @@ class AgentRunner extends events_1.EventEmitter {
|
|
|
941
1212
|
else {
|
|
942
1213
|
this.writeAutoForward(mapKey, text);
|
|
943
1214
|
}
|
|
1215
|
+
// Assistant output reached the channel — mark the turn delivered so
|
|
1216
|
+
// a later recovery does not resend a message that was answered (C1).
|
|
1217
|
+
const lt = this.lastTurn.get(mapKey);
|
|
1218
|
+
if (lt)
|
|
1219
|
+
lt.delivered = true;
|
|
944
1220
|
}
|
|
945
1221
|
replyCalled = false; // reset for next turn
|
|
946
1222
|
replyToolUseId = null;
|
|
@@ -1261,6 +1537,30 @@ class AgentRunner extends events_1.EventEmitter {
|
|
|
1261
1537
|
}
|
|
1262
1538
|
// Process will be re-spawned on next incoming message
|
|
1263
1539
|
}
|
|
1540
|
+
/**
|
|
1541
|
+
* The 32MB-recovery rungs for a given healthy cap: the ladder sizes STRICTLY
|
|
1542
|
+
* below the cap, in descending order. Filtering by `< cap` (not `<=`) drops any
|
|
1543
|
+
* rung equal to or above the cap so a lowered cap never yields a recovery step
|
|
1544
|
+
* that re-injects the same (or more) history — every step actually shrinks.
|
|
1545
|
+
*/
|
|
1546
|
+
recoveryRungs(configuredMax) {
|
|
1547
|
+
return TOO_LARGE_HISTORY_LADDER.filter(r => r < configuredMax);
|
|
1548
|
+
}
|
|
1549
|
+
/**
|
|
1550
|
+
* History re-injection cap for a spawn, given the configured healthy cap and how
|
|
1551
|
+
* many consecutive 32MB recoveries have happened on the session. recoveryCount 0
|
|
1552
|
+
* = healthy → the full configured cap. Each later recovery drops to the next
|
|
1553
|
+
* rung strictly below the cap; once those are exhausted it stays at 0 (no
|
|
1554
|
+
* history). Kept in sync with the exhaustion threshold in handleRequestTooLarge.
|
|
1555
|
+
*/
|
|
1556
|
+
spawnHistoryLimit(configuredMax, recoveryCount) {
|
|
1557
|
+
if (recoveryCount <= 0)
|
|
1558
|
+
return configuredMax;
|
|
1559
|
+
const rungs = this.recoveryRungs(configuredMax);
|
|
1560
|
+
if (rungs.length === 0)
|
|
1561
|
+
return 0;
|
|
1562
|
+
return rungs[Math.min(recoveryCount - 1, rungs.length - 1)];
|
|
1563
|
+
}
|
|
1264
1564
|
/**
|
|
1265
1565
|
* Unified recovery for the recoverable "Request too large (max 32MB)" error.
|
|
1266
1566
|
* Reached from two backends that surface the SAME error differently:
|
|
@@ -1270,21 +1570,26 @@ class AgentRunner extends events_1.EventEmitter {
|
|
|
1270
1570
|
* (is_error + "Request too large (max"); the long-lived process otherwise
|
|
1271
1571
|
* stays alive and rejects every subsequent turn forever (Bug B).
|
|
1272
1572
|
*
|
|
1273
|
-
* Each consecutive recovery shrinks the history re-injected on the next spawn
|
|
1274
|
-
*
|
|
1275
|
-
*
|
|
1276
|
-
*
|
|
1277
|
-
*
|
|
1278
|
-
*
|
|
1573
|
+
* Each consecutive recovery shrinks the history re-injected on the next spawn,
|
|
1574
|
+
* stepping down the TOO_LARGE_HISTORY_LADDER rungs strictly below the configured
|
|
1575
|
+
* cap (default 50 → 40→30→20→10→0), so a pathological context drops under the
|
|
1576
|
+
* 32MB ceiling. The respawn happens on the user's NEXT message (no auto-loop),
|
|
1577
|
+
* and the counter resets on the next successful result. Once even a zero-history
|
|
1578
|
+
* spawn still trips 32MB, stop escalating and ask the user to /clear rather than
|
|
1579
|
+
* climb the ladder again.
|
|
1279
1580
|
*/
|
|
1280
1581
|
handleRequestTooLarge(mapKey, proc) {
|
|
1281
1582
|
proc.setProcessing(false);
|
|
1583
|
+
// Recovery steps through the ladder rungs strictly below the configured cap;
|
|
1584
|
+
// the number of those rungs is how many shrink attempts exist before even the
|
|
1585
|
+
// smallest (0-history) spawn has been tried and still trips 32MB.
|
|
1586
|
+
const configuredMax = (0, process_1.resolveMaxHistoryMessages)(this.agentConfig.history?.maxHistoryMessages, this.gatewayConfig.gateway.history?.maxHistoryMessages);
|
|
1587
|
+
const stepCount = this.recoveryRungs(configuredMax).length; // shrink attempts available
|
|
1282
1588
|
const count = (this.tooLargeRecoveries.get(mapKey) ?? 0) + 1;
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
//
|
|
1286
|
-
|
|
1287
|
-
this.tooLargeRecoveries.set(mapKey, lastRung);
|
|
1589
|
+
if (count > stepCount) {
|
|
1590
|
+
// Even the smallest rung tripped 32MB — context can't shrink further.
|
|
1591
|
+
// Pin the counter at the last step and always surface the /clear next step.
|
|
1592
|
+
this.tooLargeRecoveries.set(mapKey, stepCount);
|
|
1288
1593
|
this.logger.error('Request too large persists with zero re-injected history', { mapKey, count });
|
|
1289
1594
|
this.writeAutoForward(mapKey, '⚠️ ยังเกิน 32MB แม้จะล้าง context จนว่างแล้ว — พิมพ์ /clear เพื่อเริ่มเซสชันใหม่ หรือ /restart ค่ะ');
|
|
1290
1595
|
// Restart ONCE to clear the wedged process the first time the ladder is
|
|
@@ -1297,7 +1602,7 @@ class AgentRunner extends events_1.EventEmitter {
|
|
|
1297
1602
|
}
|
|
1298
1603
|
this.tooLargeRecoveries.set(mapKey, count);
|
|
1299
1604
|
this.logger.warn('Request too large (32MB) — restarting with reduced history', {
|
|
1300
|
-
mapKey, attempt: count, nextHistoryLimit:
|
|
1605
|
+
mapKey, attempt: count, nextHistoryLimit: this.spawnHistoryLimit(configuredMax, count),
|
|
1301
1606
|
});
|
|
1302
1607
|
// Ordering matters and makes the notice delivery race-free: writeAutoForward
|
|
1303
1608
|
// persists the `.forward` file synchronously HERE, before restartProcess()
|