@yeaft/webchat-agent 1.0.313 → 1.0.315

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.
@@ -9,11 +9,15 @@
9
9
  */
10
10
 
11
11
  import { defineTool } from './types.js';
12
- import { spawn } from 'child_process';
13
12
  import { existsSync } from 'fs';
14
- import { resolve } from 'path';
13
+ import { dirname, resolve } from 'path';
14
+ import { fileURLToPath } from 'url';
15
15
  import { buildShellInvocation, getRuntimePlatformInfo } from '../runtime-platform.js';
16
- import { wrapInvocationInSystemdUserScope } from '../systemd-scope.js';
16
+ import {
17
+ wrapInvocationInSystemdUserScope,
18
+ wrapInvocationInSystemdUserService,
19
+ } from '../systemd-scope.js';
20
+ import { runProcess } from './process-runner.js';
17
21
 
18
22
  export { buildShellInvocation };
19
23
 
@@ -25,117 +29,69 @@ const DEFAULT_TIMEOUT_MS = 120_000;
25
29
 
26
30
  /** Max timeout in ms (10 minutes). */
27
31
  const MAX_TIMEOUT_MS = 600_000;
32
+ /**
33
+ * ToolRegistry must not preempt Bash's own process timeout. Bash terminates
34
+ * the process tree and waits for close before returning exit 124; the grace
35
+ * covers TERM/KILL escalation and the bounded close-confirmation window.
36
+ */
37
+ const REGISTRY_TIMEOUT_MS = MAX_TIMEOUT_MS + 15_000;
38
+ const LINUX_NAMESPACE_HELPER = resolve(
39
+ dirname(fileURLToPath(import.meta.url)),
40
+ '..',
41
+ 'linux-process-namespace.js',
42
+ );
28
43
 
29
44
  /**
30
45
  * Run a command in a child process.
31
46
  * @returns {Promise<{ stdout: string, stderr: string, exitCode: number, timedOut: boolean }>}
32
47
  */
33
48
  function runCommand(command, { cwd, timeout, signal, runtimePlatform }) {
34
- return new Promise((resolve) => {
35
- const platform = runtimePlatform || getRuntimePlatformInfo();
36
- const env = { ...process.env, TERM: 'dumb', FORCE_COLOR: '0' };
37
- const baseInvocation = buildShellInvocation(command, { runtimePlatform: platform });
38
- const invocation = wrapInvocationInSystemdUserScope(baseInvocation, {
49
+ const platform = runtimePlatform || getRuntimePlatformInfo();
50
+ const env = { ...process.env, TERM: 'dumb', FORCE_COLOR: '0' };
51
+ const baseInvocation = buildShellInvocation(command, { runtimePlatform: platform });
52
+ let invocation = wrapInvocationInSystemdUserScope(baseInvocation, {
53
+ runtimePlatform: platform,
54
+ env,
55
+ scopeId: `foreground-${Date.now()}-${process.pid}`,
56
+ });
57
+ if (platform.isLinux && !invocation.systemdControl) {
58
+ invocation = wrapInvocationInSystemdUserService(baseInvocation, {
39
59
  runtimePlatform: platform,
40
60
  env,
41
- scopeId: `foreground-${Date.now()}-${process.pid}`,
42
- });
43
- const proc = spawn(invocation.command, invocation.args, {
44
61
  cwd,
45
- env,
46
- stdio: ['ignore', 'pipe', 'pipe'],
47
- detached: !platform.isWindows,
48
- windowsHide: true,
62
+ unitId: `foreground-${Date.now()}-${process.pid}`,
49
63
  });
50
-
51
- let stdout = '';
52
- let stderr = '';
53
- let stdoutTruncated = false;
54
- let stderrTruncated = false;
55
- let timedOut = false;
56
- let settled = false;
57
- let timeoutId = null;
58
-
59
- const finish = (result) => {
60
- if (settled) return;
61
- settled = true;
62
- if (timeoutId) clearTimeout(timeoutId);
63
- resolve(result);
64
- };
65
-
66
- const killProcess = () => {
67
- try {
68
- if (!platform.isWindows && proc.pid) {
69
- process.kill(-proc.pid, 'SIGTERM');
70
- return;
71
- }
72
- } catch {
73
- // Fall back to killing the shell process below.
74
- }
75
- try {
76
- proc.kill('SIGTERM');
77
- } catch {
78
- // ignore
79
- }
80
- };
81
-
82
- proc.stdout.on('data', (chunk) => {
83
- if (stdout.length < MAX_OUTPUT) {
84
- stdout += chunk.toString();
85
- if (stdout.length > MAX_OUTPUT) {
86
- stdout = stdout.slice(0, MAX_OUTPUT);
87
- stdoutTruncated = true;
88
- }
89
- }
90
- });
91
-
92
- proc.stderr.on('data', (chunk) => {
93
- if (stderr.length < MAX_OUTPUT) {
94
- stderr += chunk.toString();
95
- if (stderr.length > MAX_OUTPUT) {
96
- stderr = stderr.slice(0, MAX_OUTPUT);
97
- stderrTruncated = true;
98
- }
99
- }
100
- });
101
-
102
- timeoutId = setTimeout(() => {
103
- timedOut = true;
104
- killProcess();
105
- finish({
106
- stdout,
107
- stderr: stderr + `\nProcess timed out after ${timeout}ms`,
108
- exitCode: 124,
109
- timedOut: true,
110
- });
111
- }, timeout);
112
-
113
- if (signal) {
114
- signal.addEventListener('abort', () => {
115
- killProcess();
116
- }, { once: true });
64
+ }
65
+ if (platform.isLinux && !invocation.systemdControl) {
66
+ const unsharePath = env.YEAFT_UNSHARE_PATH || '/usr/bin/unshare';
67
+ if (!existsSync(unsharePath)) {
68
+ throw new Error(
69
+ 'Foreground Bash requires systemd-run or unshare on Linux so timeout can guarantee complete process-tree cleanup. Use background=true or install util-linux.',
70
+ );
117
71
  }
118
-
119
- proc.on('close', (code, signalName) => {
120
- if (stdoutTruncated) stdout += '\n[Output truncated]';
121
- if (stderrTruncated) stderr += '\n[Output truncated]';
122
- finish({
123
- stdout,
124
- stderr,
125
- exitCode: timedOut ? 124 : (code ?? (signalName ? 128 : 1)),
126
- timedOut,
127
- });
128
- });
129
-
130
- proc.on('error', (err) => {
131
- finish({
132
- stdout,
133
- stderr: `Error spawning process: ${err.message}`,
134
- exitCode: 1,
135
- timedOut: false,
136
- });
137
- });
138
- });
72
+ invocation = {
73
+ command: process.execPath,
74
+ args: [LINUX_NAMESPACE_HELPER, '--', baseInvocation.command, ...(baseInvocation.args || [])],
75
+ family: baseInvocation.family,
76
+ systemdControl: null,
77
+ };
78
+ }
79
+ return runProcess(invocation.command, invocation.args, {
80
+ cwd,
81
+ env,
82
+ signal,
83
+ timeoutMs: timeout,
84
+ maxBytes: MAX_OUTPUT,
85
+ requireExitConfirmation: true,
86
+ systemdScope: invocation.systemdControl,
87
+ onSettled: invocation.cleanup || null,
88
+ platform: platform.platform,
89
+ }).then(result => ({
90
+ stdout: result.stdout,
91
+ stderr: result.stderr,
92
+ exitCode: result.code,
93
+ timedOut: result.timedOut,
94
+ }));
139
95
  }
140
96
 
141
97
  export default defineTool({
@@ -211,6 +167,7 @@ Guidelines:
211
167
  required: ['command'],
212
168
  },
213
169
  errorOutput: null,
170
+ timeoutMs: REGISTRY_TIMEOUT_MS,
214
171
  isConcurrencySafe: () => false,
215
172
  isReadOnly: () => false,
216
173
  isDestructive: (input) => {
@@ -281,7 +238,8 @@ Guidelines:
281
238
  }
282
239
  return output || '(no output)';
283
240
  } catch (err) {
284
- throw new Error(err.message);
241
+ if (err?.name === 'ProcessTerminationError') err.fatalToolTimeout = true;
242
+ throw err;
285
243
  }
286
244
  },
287
245
  });
@@ -4,6 +4,16 @@ import { StringDecoder } from 'node:string_decoder';
4
4
  const DEFAULT_MAX_BYTES = 512 * 1024;
5
5
  const DEFAULT_KILL_GRACE_MS = 250;
6
6
  const DEFAULT_FORCE_SETTLE_MS = 1000;
7
+ const CONFIRMATION_POLL_MS = 25;
8
+
9
+ export class ProcessTerminationError extends Error {
10
+ constructor(command, forceSettleMs) {
11
+ super(`Process tree did not exit within ${forceSettleMs}ms after SIGKILL: ${command}`);
12
+ this.name = 'ProcessTerminationError';
13
+ this.command = command;
14
+ this.forceSettleMs = forceSettleMs;
15
+ }
16
+ }
7
17
 
8
18
  function abortError(signal) {
9
19
  if (signal?.reason instanceof Error && signal.reason.name === 'AbortError') return signal.reason;
@@ -14,7 +24,54 @@ function abortError(signal) {
14
24
  return error;
15
25
  }
16
26
 
17
- function killProcessTree(proc, signal, platform, spawnProcessSync) {
27
+ function signalSystemdScope(scope, signalName, spawnProcessSync) {
28
+ if (!scope?.unit || !scope?.systemctlPath) return false;
29
+ try {
30
+ const result = spawnProcessSync(scope.systemctlPath, [
31
+ '--user',
32
+ 'kill',
33
+ `--signal=${signalName}`,
34
+ scope.unit,
35
+ ], {
36
+ env: scope.env || process.env,
37
+ encoding: 'utf8',
38
+ stdio: 'ignore',
39
+ timeout: 5000,
40
+ windowsHide: true,
41
+ });
42
+ return !result.error && result.status === 0;
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
47
+
48
+ function isSystemdScopeInactive(scope, spawnProcessSync) {
49
+ if (!scope?.unit || !scope?.systemctlPath) return true;
50
+ try {
51
+ const result = spawnProcessSync(scope.systemctlPath, [
52
+ '--user',
53
+ 'show',
54
+ '--property=ActiveState',
55
+ '--value',
56
+ scope.unit,
57
+ ], {
58
+ env: scope.env || process.env,
59
+ encoding: 'utf8',
60
+ timeout: 5000,
61
+ windowsHide: true,
62
+ });
63
+ const state = String(result.stdout || '').trim();
64
+ if (!result.error && result.status === 0) {
65
+ return state === 'inactive' || state === 'failed';
66
+ }
67
+ return /(?:could not be found|not found|does not exist|not loaded)/i
68
+ .test(String(result.stderr || ''));
69
+ } catch {
70
+ return false;
71
+ }
72
+ }
73
+
74
+ function killProcessTree(proc, signalName, platform, spawnProcessSync, systemdScope) {
18
75
  if (!proc.pid) return false;
19
76
  if (platform === 'win32') {
20
77
  try {
@@ -25,14 +82,18 @@ function killProcessTree(proc, signal, platform, spawnProcessSync) {
25
82
  });
26
83
  if (!result.error && result.status === 0) return true;
27
84
  } catch {}
28
- try { return proc.kill(signal) !== false; } catch { return false; }
85
+ try { return proc.kill(signalName) !== false; } catch { return false; }
29
86
  }
87
+
88
+ let signalled = signalSystemdScope(systemdScope, signalName, spawnProcessSync);
30
89
  try {
31
- process.kill(-proc.pid, signal);
32
- return true;
33
- } catch {
34
- try { return proc.kill(signal) !== false; } catch { return false; }
35
- }
90
+ process.kill(-proc.pid, signalName);
91
+ signalled = true;
92
+ } catch {}
93
+ try {
94
+ if (proc.kill(signalName) !== false) signalled = true;
95
+ } catch {}
96
+ return signalled;
36
97
  }
37
98
 
38
99
  /**
@@ -40,10 +101,13 @@ function killProcessTree(proc, signal, platform, spawnProcessSync) {
40
101
  *
41
102
  * @param {string} command
42
103
  * @param {string[]} args
43
- * @param {{ cwd?: string, signal?: AbortSignal, timeoutMs?: number, maxBytes?: number, env?: NodeJS.ProcessEnv, preserveCarriageReturns?: boolean, killGraceMs?: number, forceSettleMs?: number, platform?: NodeJS.Platform, spawnProcess?: typeof spawn, spawnProcessSync?: typeof spawnSync }} [options]
104
+ * @param {{ cwd?: string, signal?: AbortSignal, timeoutMs?: number, maxBytes?: number, env?: NodeJS.ProcessEnv, preserveCarriageReturns?: boolean, killGraceMs?: number, forceSettleMs?: number, requireExitConfirmation?: boolean, systemdScope?: { unit: string, systemctlPath: string, env?: NodeJS.ProcessEnv } | null, onSettled?: (() => void) | null, platform?: NodeJS.Platform, spawnProcess?: typeof spawn, spawnProcessSync?: typeof spawnSync }} [options]
44
105
  */
45
106
  export function runProcess(command, args, options = {}) {
46
- if (options.signal?.aborted) return Promise.reject(abortError(options.signal));
107
+ if (options.signal?.aborted) {
108
+ try { options.onSettled?.(); } catch {}
109
+ return Promise.reject(abortError(options.signal));
110
+ }
47
111
 
48
112
  return new Promise((resolve, reject) => {
49
113
  const platform = options.platform || process.platform;
@@ -58,13 +122,20 @@ export function runProcess(command, args, options = {}) {
58
122
  const forceSettleMs = Number.isFinite(options.forceSettleMs)
59
123
  ? Math.max(1, options.forceSettleMs)
60
124
  : DEFAULT_FORCE_SETTLE_MS;
61
- const proc = spawnProcess(command, args, {
62
- cwd: options.cwd,
63
- env: options.env || process.env,
64
- stdio: ['ignore', 'pipe', 'pipe'],
65
- windowsHide: true,
66
- detached: platform !== 'win32',
67
- });
125
+ let proc;
126
+ try {
127
+ proc = spawnProcess(command, args, {
128
+ cwd: options.cwd,
129
+ env: options.env || process.env,
130
+ stdio: ['ignore', 'pipe', 'pipe'],
131
+ windowsHide: true,
132
+ detached: platform !== 'win32',
133
+ });
134
+ } catch (error) {
135
+ try { options.onSettled?.(); } catch {}
136
+ reject(error);
137
+ return;
138
+ }
68
139
  const stdout = [];
69
140
  const stderr = [];
70
141
  let stdoutBytes = 0;
@@ -77,26 +148,35 @@ export function runProcess(command, args, options = {}) {
77
148
  let aborted = false;
78
149
  let stopRequested = false;
79
150
  let forceRequested = false;
151
+ let directClosed = false;
152
+ let directCode = null;
80
153
  let timer = null;
81
154
  let forceTimer = null;
82
155
  let forceSettleTimer = null;
156
+ let confirmationTimer = null;
83
157
 
84
158
  let onStdout;
85
159
  let onStderr;
86
160
  let onError;
87
161
  let onClose;
162
+ let cleanedUp = false;
88
163
  const cleanup = () => {
164
+ if (cleanedUp) return;
165
+ cleanedUp = true;
89
166
  if (timer) clearTimeout(timer);
90
167
  if (forceTimer) clearTimeout(forceTimer);
91
168
  if (forceSettleTimer) clearTimeout(forceSettleTimer);
169
+ if (confirmationTimer) clearInterval(confirmationTimer);
92
170
  timer = null;
93
171
  forceTimer = null;
94
172
  forceSettleTimer = null;
173
+ confirmationTimer = null;
95
174
  options.signal?.removeEventListener('abort', onAbort);
96
175
  if (onStdout) proc.stdout?.off('data', onStdout);
97
176
  if (onStderr) proc.stderr?.off('data', onStderr);
98
177
  if (onError) proc.off('error', onError);
99
178
  if (onClose) proc.off('close', onClose);
179
+ try { options.onSettled?.(); } catch {}
100
180
  };
101
181
  const decode = (chunks, wasTruncated, preserveCarriageReturns = false) => {
102
182
  const decoder = new StringDecoder('utf8');
@@ -104,10 +184,14 @@ export function runProcess(command, args, options = {}) {
104
184
  if (!wasTruncated) value += decoder.end();
105
185
  return preserveCarriageReturns ? value : value.replace(/\r/g, '');
106
186
  };
107
- const finish = code => {
187
+ const finish = (code, error = null) => {
108
188
  if (settled) return;
109
189
  settled = true;
110
190
  cleanup();
191
+ if (error) {
192
+ reject(error);
193
+ return;
194
+ }
111
195
  if (aborted) {
112
196
  reject(abortError(options.signal));
113
197
  return;
@@ -120,12 +204,40 @@ export function runProcess(command, args, options = {}) {
120
204
  timedOut,
121
205
  });
122
206
  };
207
+ const terminationConfirmed = () => {
208
+ if (!options.requireExitConfirmation) return directClosed;
209
+ const scopeInactive = isSystemdScopeInactive(options.systemdScope, spawnProcessSync);
210
+ return directClosed && scopeInactive;
211
+ };
212
+ const maybeFinishStopped = () => {
213
+ if (settled || !stopRequested || !terminationConfirmed()) return false;
214
+ finish(directCode);
215
+ return true;
216
+ };
217
+ const startConfirmationPolling = () => {
218
+ if (!options.requireExitConfirmation || confirmationTimer) return;
219
+ confirmationTimer = setInterval(maybeFinishStopped, CONFIRMATION_POLL_MS);
220
+ };
123
221
  const forceStop = () => {
124
222
  if (settled || forceRequested) return;
125
223
  forceRequested = true;
126
- killProcessTree(proc, 'SIGKILL', platform, spawnProcessSync);
127
- forceSettleTimer = setTimeout(() => finish(null), forceSettleMs);
128
- forceSettleTimer.unref?.();
224
+ killProcessTree(
225
+ proc,
226
+ 'SIGKILL',
227
+ platform,
228
+ spawnProcessSync,
229
+ options.systemdScope,
230
+ );
231
+ if (maybeFinishStopped()) return;
232
+ forceSettleTimer = setTimeout(() => {
233
+ if (maybeFinishStopped()) return;
234
+ finish(
235
+ null,
236
+ options.requireExitConfirmation
237
+ ? new ProcessTerminationError(command, forceSettleMs)
238
+ : null,
239
+ );
240
+ }, forceSettleMs);
129
241
  };
130
242
  const stop = () => {
131
243
  if (settled || stopRequested) return;
@@ -134,14 +246,27 @@ export function runProcess(command, args, options = {}) {
134
246
  // taskkill must run while the parent PID still identifies the tree.
135
247
  // It is already forceful, so do not wait for the direct child to exit.
136
248
  forceRequested = true;
137
- killProcessTree(proc, 'SIGKILL', platform, spawnProcessSync);
249
+ killProcessTree(proc, 'SIGKILL', platform, spawnProcessSync, null);
138
250
  if (!settled) {
139
- forceSettleTimer = setTimeout(() => finish(null), forceSettleMs);
140
- forceSettleTimer.unref?.();
251
+ forceSettleTimer = setTimeout(() => {
252
+ finish(
253
+ null,
254
+ options.requireExitConfirmation
255
+ ? new ProcessTerminationError(command, forceSettleMs)
256
+ : null,
257
+ );
258
+ }, forceSettleMs);
141
259
  }
142
260
  return;
143
261
  }
144
- killProcessTree(proc, 'SIGTERM', platform, spawnProcessSync);
262
+ startConfirmationPolling();
263
+ killProcessTree(
264
+ proc,
265
+ 'SIGTERM',
266
+ platform,
267
+ spawnProcessSync,
268
+ options.systemdScope,
269
+ );
145
270
  forceTimer = setTimeout(forceStop, killGraceMs);
146
271
  forceTimer.unref?.();
147
272
  };
@@ -174,10 +299,19 @@ export function runProcess(command, args, options = {}) {
174
299
 
175
300
  onStdout = chunk => capture(stdout, chunk, true);
176
301
  onStderr = chunk => capture(stderr, chunk, false);
302
+ const finishStoppedChild = () => {
303
+ if (!forceRequested) {
304
+ if (forceTimer) clearTimeout(forceTimer);
305
+ forceTimer = null;
306
+ forceStop();
307
+ }
308
+ maybeFinishStopped();
309
+ };
177
310
  onError = error => {
178
311
  if (settled) return;
179
312
  if (stopRequested) {
180
- finish(null);
313
+ directClosed = true;
314
+ finishStoppedChild();
181
315
  return;
182
316
  }
183
317
  settled = true;
@@ -185,11 +319,11 @@ export function runProcess(command, args, options = {}) {
185
319
  reject(error);
186
320
  };
187
321
  onClose = code => {
188
- if (stopRequested && !forceRequested) {
189
- // The direct child is gone. Kill any process that remained in its
190
- // detached group before releasing the tool call.
191
- forceRequested = true;
192
- killProcessTree(proc, 'SIGKILL', platform, spawnProcessSync);
322
+ directClosed = true;
323
+ directCode = code;
324
+ if (stopRequested) {
325
+ finishStoppedChild();
326
+ return;
193
327
  }
194
328
  finish(code);
195
329
  };
@@ -134,15 +134,16 @@ function localizeParameters(parameters, language, toolName, parameterOverrides =
134
134
  * does nothing.
135
135
  *
136
136
  * Fix: race the tool's promise against a timer. On timeout we throw a
137
- * loud error the engine's existing catch (engine.js: tool-execute path)
138
- * emits `tool_end{isError:true}` and the loop continues normally. Loud
139
- * failure beats silent stall.
137
+ * loud error and the engine emits `tool_end{isError:true}`. Read-only and
138
+ * run-scoped failures can continue normally; an external side-effecting tool
139
+ * is terminal because its detached promise may still be running and replaying
140
+ * or continuing beside it is unsafe. Either way the query emits a diagnostic
141
+ * terminal boundary instead of silently stalling.
140
142
  *
141
- * 90s is comfortably above the typical tool budget (most tools complete
142
- * in <1s; bash and web-fetch can run tens of seconds; web-search is
143
- * usually <10s) but well below the 120s bridge-level watchdog so the
144
- * tool-level signal fires first and surfaces a useful per-tool diagnosis
145
- * rather than an opaque "VP stalled" log.
143
+ * 90s is comfortably above the typical tool budget (most tools complete in
144
+ * <1s; web-fetch can run tens of seconds; web-search is usually <10s). Tools
145
+ * with a longer owned deadline, such as Bash and WaitAgent, override this so
146
+ * their own cancellation/timeout result reaches the model first.
146
147
  *
147
148
  * Override per-tool by setting `tool.timeoutMs` on the ToolDef. Set to
148
149
  * 0 (or a negative number) to disable the timeout for that tool — only
@@ -1111,24 +1111,19 @@ function queryTimeoutMsForSession(sessionId = null) {
1111
1111
  /**
1112
1112
  * Secondary watchdog grace period (ms).
1113
1113
  *
1114
- * After the active query timeout of silence the per-VP `vpAbort` is fired.
1114
+ * After the active provider timeout of silence the per-VP `vpAbort` is fired.
1115
1115
  * Normal turns use {@link QUERY_TIMEOUT_MS}; high-reasoning session turns use
1116
- * {@link HIGH_REASONING_QUERY_TIMEOUT_MS} because large tool-result arcs can
1117
- * legitimately spend longer before the provider emits the first SSE event.
1118
- * That's enough on its own when adapters / tools cooperate with the
1119
- * AbortSignal the engine throws `AbortError`, runVpTurn's catch emits
1120
- * `result{stopped:true}`, the driver `finally` emits `vp_typing_end`, and
1121
- * the user is unstuck.
1116
+ * {@link HIGH_REASONING_QUERY_TIMEOUT_MS}. Tool execution and declared retry /
1117
+ * async-task waits pause this watchdog because those phases own separate,
1118
+ * explicit deadlines. Treating a long tool as silent provider work races the
1119
+ * tool's own timeout and can abort the whole query before its error result is
1120
+ * returned to the model.
1122
1121
  *
1123
- * If a tool ignores `signal` and never resolves, the engine generator's
1124
- * `await tool.execute(...)` is permanently blocked: the abort fires on a
1125
- * controller it never observes, and runVpTurn never returns. The same
1126
- * applies to an adapter `stream()` that ignores `signal` (e.g. a stuck
1127
- * SSE connection) or to tools that legitimately opt out of the per-tool
1128
- * timeout via `timeoutMs <= 0`. The per-tool timeout in
1129
- * {@link import('./tools/registry.js').DEFAULT_TOOL_TIMEOUT_MS}
1130
- * is the primary cure for the tool-ignore-signal case; this bridge-level
1131
- * escalation strictly extends it to cover the adapter and opt-out cases.
1122
+ * If an adapter ignores `signal`, runVpTurn still cannot return after the
1123
+ * abort. The per-tool timeout in
1124
+ * {@link import('./tools/registry.js').DEFAULT_TOOL_TIMEOUT_MS} independently
1125
+ * protects tool execution; this bridge-level escalation covers the adapter
1126
+ * and any other path that ignores the query abort.
1132
1127
  * Without a second-stage escalation the typing dots hang forever —
1133
1128
  * exactly the "halts mid-execution with no turn_end" symptom.
1134
1129
  *
@@ -3799,6 +3794,8 @@ export function __testHandleEngineEvent(event, hctx) {
3799
3794
  function handleEngineEvent(event, hctx) {
3800
3795
  const terminalTurnEnd = event.type === 'turn_end' && event.terminal === true;
3801
3796
  const managesQueryTimer = terminalTurnEnd
3797
+ || event.type === 'tool_start'
3798
+ || event.type === 'tool_end'
3802
3799
  || event.type === 'async_task_wait_start'
3803
3800
  || event.type === 'async_task_wait_end'
3804
3801
  || event.type === 'llm_retry';
@@ -3886,6 +3883,11 @@ function handleEngineEvent(event, hctx) {
3886
3883
  break;
3887
3884
 
3888
3885
  case 'tool_start':
3886
+ // ToolRegistry and individual tools own their execution deadlines. The
3887
+ // bridge watchdog only protects provider silence; leaving it armed here
3888
+ // races legitimate long tools and can abort the whole query just before
3889
+ // the tool returns a bounded error result for the model to handle.
3890
+ if (typeof hctx.pauseQueryTimer === 'function') hctx.pauseQueryTimer();
3889
3891
  sendSessionEvent({
3890
3892
  type: 'tool_start',
3891
3893
  id: event.id,
@@ -3951,12 +3953,12 @@ function handleEngineEvent(event, hctx) {
3951
3953
  is_error: event.isError || false,
3952
3954
  }],
3953
3955
  }, envelope);
3954
- // Tool finished. Do NOT speculatively flip to 'thinking' the
3955
- // engine may emit more text-deltas (→ 'streaming') OR go straight
3956
- // to end_turn (→ 'idle' via runVpTurn's finally). The old
3957
- // speculative transition caused a visible 'tool thinking
3958
- // streaming' flicker on every tool call. Hold the 'tool' state
3959
- // until the next real event arrives.
3956
+ // Tool execution is over, so the next silent phase is provider work
3957
+ // again. Re-arm the provider watchdog before waiting for the next event.
3958
+ if (typeof hctx.resetQueryTimer === 'function') hctx.resetQueryTimer();
3959
+ // Do NOT speculatively flip to 'thinking' the engine may emit more
3960
+ // text-deltas (→ 'streaming') OR go straight to end_turn (→ 'idle' via
3961
+ // runVpTurn's finally). Hold the 'tool' state until a real event arrives.
3960
3962
  break;
3961
3963
  }
3962
3964
 
@@ -1134,6 +1134,7 @@ export class WorkItemRunner {
1134
1134
  let loopCount = 0;
1135
1135
  let toolCount = 0;
1136
1136
  let checkpoint = null;
1137
+ let terminalEngineError = null;
1137
1138
  const toolInputs = new Map();
1138
1139
  const usageStats = {
1139
1140
  llmRequestCount: 0,
@@ -1298,9 +1299,19 @@ export class WorkItemRunner {
1298
1299
  resource: checkpointResource(event.name, input, workDir),
1299
1300
  });
1300
1301
  }
1302
+ else if (event?.type === 'error' && !terminalEngineError) {
1303
+ terminalEngineError = event.error instanceof Error
1304
+ ? event.error
1305
+ : new Error(String(event.error?.message || event.error || 'Work Center Engine failed'));
1306
+ }
1301
1307
  if (event?.type === 'text_delta' && typeof event.text === 'string') text += event.text;
1302
1308
  reportProgress(event?.type === 'loop');
1303
1309
  }
1310
+ // Engine.query owns the terminal protocol and converts unexpected faults
1311
+ // into error + terminal turn_end. Consume that boundary completely, then
1312
+ // preserve Work Center's historical rejection semantics so Run fencing,
1313
+ // hazardous side-effect handling, and retry policy still see the failure.
1314
+ if (terminalEngineError) throw terminalEngineError;
1304
1315
  } catch (error) {
1305
1316
  error.workItemExecutionStats = currentProgress();
1306
1317
  throw error;