@yeaft/webchat-agent 1.0.286 → 1.0.287

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.
@@ -1 +1 @@
1
- {"version":"1.0.286"}
1
+ {"version":"1.0.287"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.286",
3
+ "version": "1.0.287",
4
4
  "description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/yeaft/engine.js CHANGED
@@ -81,6 +81,9 @@ const MAX_CONTINUE_TURNS = 3;
81
81
  /** Bound the best-effort post-turn AMS LLM call independently of the user turn. */
82
82
  const AMS_ADJUST_TIMEOUT_MS = 30_000;
83
83
 
84
+ /** Maximum silence while a visible turn waits for a result-producing task. */
85
+ const DEFAULT_ASYNC_TASK_WAIT_TIMEOUT_MS = 120_000;
86
+
84
87
  // ─── LLM retry policy defaults ──────────────────────────────────
85
88
  // Hard-coded floor / ceiling for retry behaviour. The engine reads the
86
89
  // effective policy from `config.llmRetry` so users can dial these via
@@ -629,6 +632,7 @@ export class Engine {
629
632
  * onUnregister?: (taskId:string, engine:Engine) => void,
630
633
  * onConsumed?: (taskId:string, engine:Engine) => void,
631
634
  * onUndelivered?: (taskId:string, delivery:object, engine:Engine) => void,
635
+ * onDeferred?: (taskId:string, engine:Engine) => void,
632
636
  * } | null}
633
637
  */
634
638
  #asyncTaskCoordinator = null;
@@ -3209,14 +3213,17 @@ export class Engine {
3209
3213
  if ((stopReason !== 'tool_use' || toolCalls.length === 0)
3210
3214
  && this.#pendingAsyncTaskIds.size > 0
3211
3215
  && !signal?.aborted) {
3216
+ const asyncTaskWaitTimeoutMs = this.#asyncTaskWaitTimeoutMs();
3217
+ const deferredTaskIds = [];
3212
3218
  // Drop into a wait loop. The loop wakes on (a) any task
3213
3219
  // terminal event delivered via `notifyAsyncTaskCompleted`, (b)
3214
3220
  // a fresh user append (which is honored as a higher priority
3215
3221
  // user input), or (c) abort. On wake we re-check: if either
3216
3222
  // queue has content, drain + splice + continue the outer loop.
3217
3223
  // If both queues are empty AND we still have pending tasks AND
3218
- // we're not aborted, we just keep waiting. This is the only
3219
- // place query() can block on something other than the LLM stream.
3224
+ // we're not aborted, wait only until the oldest tracked task has
3225
+ // been silent for the bounded window. Stale ownership is then
3226
+ // released so a later terminal event uses the rescue-turn path.
3220
3227
  yield {
3221
3228
  type: 'async_task_wait_start',
3222
3229
  turnId: queryTurnId,
@@ -3237,7 +3244,10 @@ export class Engine {
3237
3244
  && this.#pendingUserMessages.length === 0
3238
3245
  && !this.#externalUserWakePending) {
3239
3246
  if (this.#pendingAsyncTaskIds.size === 0) break;
3240
- await this.#waitForAsyncWake(signal);
3247
+ const waitMs = this.#nextAsyncTaskWaitMs(asyncTaskWaitTimeoutMs);
3248
+ if (await this.#waitForAsyncWake(signal, waitMs) === 'timeout') {
3249
+ deferredTaskIds.push(...this.#deferExpiredAsyncTasks(asyncTaskWaitTimeoutMs));
3250
+ }
3241
3251
  }
3242
3252
  yield {
3243
3253
  type: 'async_task_wait_end',
@@ -3246,6 +3256,8 @@ export class Engine {
3246
3256
  threadId,
3247
3257
  aborted: Boolean(signal?.aborted),
3248
3258
  remainingTaskIds: Array.from(this.#pendingAsyncTaskIds),
3259
+ timedOut: deferredTaskIds.length > 0,
3260
+ deferredTaskIds,
3249
3261
  };
3250
3262
  if (signal?.aborted) {
3251
3263
  yield { type: 'aborted', reason: this.#abortReason || 'external', turnNumber, threadId };
@@ -4186,6 +4198,69 @@ export class Engine {
4186
4198
  try { this.#asyncTaskCoordinator?.onRegister?.(taskId, this); } catch { /* coord must not throw into tools */ }
4187
4199
  }
4188
4200
 
4201
+ #asyncTaskWaitTimeoutMs() {
4202
+ const configured = Number(this.#config?.asyncTaskWaitTimeoutMs);
4203
+ if (!Number.isFinite(configured)) return DEFAULT_ASYNC_TASK_WAIT_TIMEOUT_MS;
4204
+ return Math.max(1, Math.min(60 * 60_000, Math.floor(configured)));
4205
+ }
4206
+
4207
+ #asyncTaskLastActivityAt(taskId) {
4208
+ if (!this.#taskManager || typeof this.#taskManager.getTask !== 'function') return null;
4209
+ const sessionId = this.#sessionId || 'default';
4210
+ let task = null;
4211
+ try { task = this.#taskManager.getTask(sessionId, taskId); } catch { return null; }
4212
+ if (!task || task.status !== 'running') return 0;
4213
+ const updatedAt = Date.parse(task.updatedAt || task.startedAt || task.createdAt || '');
4214
+ return Number.isFinite(updatedAt) ? updatedAt : 0;
4215
+ }
4216
+
4217
+ #nextAsyncTaskWaitMs(timeoutMs) {
4218
+ const now = Date.now();
4219
+ let next = timeoutMs;
4220
+ for (const taskId of this.#pendingAsyncTaskIds) {
4221
+ const lastActivityAt = this.#asyncTaskLastActivityAt(taskId);
4222
+ if (lastActivityAt === null) continue;
4223
+ next = Math.min(next, Math.max(1, lastActivityAt + timeoutMs - now));
4224
+ }
4225
+ return Math.max(1, next);
4226
+ }
4227
+
4228
+ /**
4229
+ * Release stale same-turn ownership without stopping the underlying tasks.
4230
+ * Active sub-agents refresh TaskManager.updatedAt from their event stream, so
4231
+ * the timeout measures silence rather than total runtime. A later terminal
4232
+ * event misses the owner map and uses the bridge rescue path.
4233
+ * @param {number} timeoutMs
4234
+ * @returns {string[]}
4235
+ */
4236
+ #deferExpiredAsyncTasks(timeoutMs) {
4237
+ if (this.#pendingAsyncTaskIds.size === 0) return [];
4238
+ const now = Date.now();
4239
+ const taskIds = Array.from(this.#pendingAsyncTaskIds).filter((taskId) => {
4240
+ const lastActivityAt = this.#asyncTaskLastActivityAt(taskId);
4241
+ return lastActivityAt === null || now - lastActivityAt >= timeoutMs;
4242
+ });
4243
+ for (const taskId of taskIds) {
4244
+ // Keep ownership visible while the coordinator decides whether this is a
4245
+ // real defer. If a terminal event already won and removed the task, the
4246
+ // coordinator can reject a stale timeout callback instead of scheduling
4247
+ // a duplicate rescue.
4248
+ try {
4249
+ if (typeof this.#asyncTaskCoordinator?.onDeferred === 'function') {
4250
+ this.#asyncTaskCoordinator.onDeferred(taskId, this);
4251
+ } else {
4252
+ this.#asyncTaskCoordinator?.onUnregister?.(taskId, this);
4253
+ }
4254
+ } catch { /* best-effort */ }
4255
+ this.#pendingAsyncTaskIds.delete(taskId);
4256
+ this.#asyncTaskToolMeta.delete(taskId);
4257
+ }
4258
+ if (taskIds.length > 0) {
4259
+ console.warn(`[Engine] async task wait silent for ${timeoutMs}ms; deferring ${taskIds.join(', ')}`);
4260
+ }
4261
+ return taskIds;
4262
+ }
4263
+
4189
4264
  #wakeAsyncTaskWaiters() {
4190
4265
  if (this.#asyncTaskWaiters.length === 0) return;
4191
4266
  const waiters = this.#asyncTaskWaiters.splice(0);
@@ -4196,30 +4271,42 @@ export class Engine {
4196
4271
 
4197
4272
  /**
4198
4273
  * Wait for *any* of: an async task terminal event, a fresh user append,
4199
- * or signal abort. Resolves immediately if any of those is already
4200
- * pending. The loop re-evaluates conditions on wake — multiple
4201
- * concurrent tasks all wake the same waiter once, then the loop drains
4202
- * everything in one iteration and decides whether to keep waiting.
4274
+ * signal abort, or the current silence budget. The loop re-evaluates all
4275
+ * queues and task activity on every wake.
4203
4276
  * @param {AbortSignal|null|undefined} signal
4204
- * @returns {Promise<void>}
4277
+ * @param {number} timeoutMs
4278
+ * @returns {Promise<'wake'|'timeout'>}
4205
4279
  */
4206
- #waitForAsyncWake(signal) {
4280
+ #waitForAsyncWake(signal, timeoutMs) {
4207
4281
  return new Promise((resolve) => {
4282
+ let settled = false;
4283
+ let timer = null;
4284
+ let onAbort = null;
4208
4285
  // Fast paths — anything already pending releases instantly. This is
4209
4286
  // the common case when a task finished between adapter loops.
4210
- if (this.#pendingTaskResultMessages.length > 0) return resolve();
4211
- if (this.#pendingTaskResultUpdates.length > 0) return resolve();
4212
- if (this.#pendingUserMessages.length > 0) return resolve();
4213
- if (this.#externalUserWakePending) return resolve();
4214
- if (signal?.aborted) return resolve();
4215
- this.#asyncTaskWaiters.push(resolve);
4287
+ if (this.#pendingTaskResultMessages.length > 0) return resolve('wake');
4288
+ if (this.#pendingTaskResultUpdates.length > 0) return resolve('wake');
4289
+ if (this.#pendingUserMessages.length > 0) return resolve('wake');
4290
+ if (this.#externalUserWakePending) return resolve('wake');
4291
+ if (signal?.aborted) return resolve('wake');
4292
+ const finish = (reason) => {
4293
+ if (settled) return;
4294
+ settled = true;
4295
+ if (timer) clearTimeout(timer);
4296
+ if (signal && onAbort) {
4297
+ try { signal.removeEventListener('abort', onAbort); } catch { /* ignore */ }
4298
+ }
4299
+ const idx = this.#asyncTaskWaiters.indexOf(wake);
4300
+ if (idx >= 0) this.#asyncTaskWaiters.splice(idx, 1);
4301
+ resolve(reason);
4302
+ };
4303
+ const wake = () => finish('wake');
4304
+ this.#asyncTaskWaiters.push(wake);
4305
+ timer = setTimeout(() => finish('timeout'), Math.max(1, Number(timeoutMs) || 1));
4306
+ timer.unref?.();
4216
4307
  if (signal && typeof signal.addEventListener === 'function') {
4217
- // Best-effort abort wakeup; the loop re-checks signal.aborted.
4218
- const onAbort = () => {
4219
- // Splice the resolver out of the wait queue so it can't double-fire.
4220
- const idx = this.#asyncTaskWaiters.indexOf(resolve);
4221
- if (idx >= 0) this.#asyncTaskWaiters.splice(idx, 1);
4222
- try { resolve(); } catch { /* ignore */ }
4308
+ onAbort = () => {
4309
+ try { finish('wake'); } catch { /* ignore */ }
4223
4310
  };
4224
4311
  try { signal.addEventListener('abort', onAbort, { once: true }); } catch { /* old runtimes */ }
4225
4312
  }
@@ -137,88 +137,105 @@ export function startSubAgent(agent, deps = {}) {
137
137
  if (agent.__driverStarted) return; // idempotent
138
138
  agent.__driverStarted = true;
139
139
 
140
- // Build sub-engine wired to the parent's adapter/stores/config but with
141
- // a restricted toolset. We DO NOT pass a conversationStore: sub-agent
142
- // turns must not pollute the user-facing conversation history. The
143
- // memory stores are shared so memory recall still works for the
144
- // sub-agent (matches parent VP persona memory).
145
- const childRegistry = buildChildToolRegistry(deps.parentToolRegistry);
146
- const subEngine = new Engine({
147
- adapter: deps.adapter,
148
- trace: deps.trace,
149
- config: { ...deps.config, _readOnly: true },
150
- conversationStore: null,
151
- memoryStore: deps.memoryStore || null,
152
- memoryShardStore: deps.memoryShardStore || null,
153
- toolRegistry: childRegistry,
154
- skillManager: deps.skillManager || null,
155
- mcpManager: deps.mcpManager || null,
156
- yeaftDir: deps.yeaftDir || null,
157
- toolStats: deps.toolStats || null,
158
- taskManager: deps.taskManager || null,
159
- });
160
- // Same-turn result plumbing: inherit the parent's coordinator so any
161
- // result-producing child task launched from this sub-agent uses the shared
162
- // owner map. Persistent shell tasks remain status-only and do not register.
163
- if (deps.asyncTaskCoordinator && typeof subEngine.setAsyncTaskCoordinator === 'function') {
164
- subEngine.setAsyncTaskCoordinator(deps.asyncTaskCoordinator);
165
- }
166
-
167
- agent.subEngine = subEngine;
168
- agent.engineMessages = agent.engineMessages || [];
169
- agent.liveness = agent.liveness || makeLiveness();
170
- agent.parentVpId = deps.parentVpId || null;
171
- agent.parentSessionId = deps.parentSessionId || null;
172
- agent.parentThreadId = deps.parentThreadId || 'main';
173
- agent.outputLog = createOutputLog(agent.id, deps.subAgentLogDir);
174
- agent.outputFile = agent.outputLog.path;
175
- if (agent.taskId && deps.taskManager && agent.parentSessionId) {
176
- try { deps.taskManager.setTaskLogPath(agent.parentSessionId, agent.taskId, agent.outputFile); } catch { /* ignore */ }
177
- }
178
- agent.outputLog.write({ type: 'sub_agent_spawned', agentId: agent.id, agentName: agent.name, mission: agent.mission || agent.task || '' });
179
-
180
- // Compose the system-prompt-overlay we want injected.
181
- const preamble = buildSpawnedPreamble({
182
- parentName: deps.parentName || 'parent',
183
- parentVpId: deps.parentVpId || null,
184
- agentName: agent.name,
185
- mission: agent.mission || agent.task || '',
186
- language: deps.language || deps.config?.language || 'en',
187
- });
140
+ let subEngine = null;
141
+ let outputLog = null;
142
+ try {
143
+ // Build sub-engine wired to the parent's adapter/stores/config but with
144
+ // a restricted toolset. We DO NOT pass a conversationStore: sub-agent
145
+ // turns must not pollute the user-facing conversation history. The
146
+ // memory stores are shared so memory recall still works for the
147
+ // sub-agent (matches parent VP persona memory).
148
+ const childRegistry = buildChildToolRegistry(deps.parentToolRegistry);
149
+ subEngine = new Engine({
150
+ adapter: deps.adapter,
151
+ trace: deps.trace,
152
+ config: { ...deps.config, _readOnly: true },
153
+ conversationStore: null,
154
+ memoryStore: deps.memoryStore || null,
155
+ memoryShardStore: deps.memoryShardStore || null,
156
+ toolRegistry: childRegistry,
157
+ skillManager: deps.skillManager || null,
158
+ mcpManager: deps.mcpManager || null,
159
+ yeaftDir: deps.yeaftDir || null,
160
+ toolStats: deps.toolStats || null,
161
+ taskManager: deps.taskManager || null,
162
+ });
163
+ // Same-turn result plumbing: inherit the parent's coordinator so any
164
+ // result-producing child task launched from this sub-agent uses the shared
165
+ // owner map. Persistent shell tasks remain status-only and do not register.
166
+ if (deps.asyncTaskCoordinator && typeof subEngine.setAsyncTaskCoordinator === 'function') {
167
+ subEngine.setAsyncTaskCoordinator(deps.asyncTaskCoordinator);
168
+ }
188
169
 
189
- const baseVpPersona =
190
- deps.parentVpPersona && typeof deps.parentVpPersona === 'object'
191
- ? { ...deps.parentVpPersona }
192
- : {};
193
- baseVpPersona.persona =
194
- [(baseVpPersona.persona || '').trim(), preamble.trim()]
195
- .filter(Boolean)
196
- .join('\n\n');
197
- if (!baseVpPersona.displayName || !String(baseVpPersona.displayName).trim()) {
198
- baseVpPersona.displayName = `${deps.parentName || 'Parent'}/${agent.name || 'sub-agent'}`;
199
- }
200
- baseVpPersona.subAgent = {
201
- parentVpId: deps.parentVpId || null,
202
- agentId: agent.id,
203
- agentName: agent.name,
204
- };
170
+ agent.subEngine = subEngine;
171
+ agent.engineMessages = agent.engineMessages || [];
172
+ agent.liveness = agent.liveness || makeLiveness();
173
+ agent.parentVpId = deps.parentVpId || null;
174
+ agent.parentSessionId = deps.parentSessionId || null;
175
+ agent.parentThreadId = deps.parentThreadId || 'main';
176
+ outputLog = createOutputLog(agent.id, deps.subAgentLogDir);
177
+ agent.outputLog = outputLog;
178
+ agent.outputFile = outputLog.path;
179
+ if (agent.taskId && deps.taskManager && agent.parentSessionId) {
180
+ try { deps.taskManager.setTaskLogPath(agent.parentSessionId, agent.taskId, agent.outputFile); } catch { /* ignore */ }
181
+ }
182
+ outputLog.write({ type: 'sub_agent_spawned', agentId: agent.id, agentName: agent.name, mission: agent.mission || agent.task || '' });
183
+
184
+ // Compose the system-prompt-overlay we want injected.
185
+ const preamble = buildSpawnedPreamble({
186
+ parentName: deps.parentName || 'parent',
187
+ parentVpId: deps.parentVpId || null,
188
+ agentName: agent.name,
189
+ mission: agent.mission || agent.task || '',
190
+ language: deps.language ?? deps.config?.language ?? 'en',
191
+ });
205
192
 
206
- agent.subVpPersona = baseVpPersona;
193
+ const baseVpPersona =
194
+ deps.parentVpPersona && typeof deps.parentVpPersona === 'object'
195
+ ? { ...deps.parentVpPersona }
196
+ : {};
197
+ baseVpPersona.persona =
198
+ [(baseVpPersona.persona || '').trim(), preamble.trim()]
199
+ .filter(Boolean)
200
+ .join('\n\n');
201
+ if (!baseVpPersona.displayName || !String(baseVpPersona.displayName).trim()) {
202
+ baseVpPersona.displayName = `${deps.parentName || 'Parent'}/${agent.name || 'sub-agent'}`;
203
+ }
204
+ baseVpPersona.subAgent = {
205
+ parentVpId: deps.parentVpId || null,
206
+ agentId: agent.id,
207
+ agentName: agent.name,
208
+ };
207
209
 
208
- // Background driver — pumps queued user messages through engine.query
209
- // turn by turn until the agent reaches a terminal state.
210
- driveSubAgent(agent, subEngine, baseVpPersona, deps).catch((err) => {
211
- // The driver normally handles its own failures (stream try/catch +
212
- // terminal transition). This .catch covers genuinely unexpected
213
- // throws between turns (e.g. inside dequeueNextUserPrompt) so we
214
- // never leave a zombie record without a terminal status.
215
- if (isTerminalAgentStatus(agent.status)) return;
216
- transitionTerminal(agent, STATUS.FAILED, {
217
- error: err && err.message ? err.message : String(err),
218
- diagnostic: 'driver_error',
219
- deps,
210
+ agent.subVpPersona = baseVpPersona;
211
+
212
+ // Background driver pumps queued user messages through engine.query
213
+ // turn by turn until the agent reaches a terminal state.
214
+ driveSubAgent(agent, subEngine, baseVpPersona, deps).catch((err) => {
215
+ // The driver normally handles its own failures (stream try/catch +
216
+ // terminal transition). This .catch covers genuinely unexpected
217
+ // throws between turns (e.g. inside dequeueNextUserPrompt) so we
218
+ // never leave a zombie record without a terminal status.
219
+ if (isTerminalAgentStatus(agent.status)) return;
220
+ transitionTerminal(agent, STATUS.FAILED, {
221
+ error: err && err.message ? err.message : String(err),
222
+ diagnostic: 'driver_error',
223
+ deps,
224
+ });
220
225
  });
221
- });
226
+ } catch (err) {
227
+ // Startup is transactional. Nothing owns these resources until the driver
228
+ // promise has been scheduled; a synchronous failure must leave the record
229
+ // restartable and release every partially-created handle.
230
+ try { outputLog?.close(); } catch { /* best-effort rollback */ }
231
+ try { subEngine?.retireAsyncTasks?.('sub_agent_startup_failed', { rescue: false }); } catch { /* best-effort rollback */ }
232
+ agent.outputLog = null;
233
+ agent.outputFile = null;
234
+ agent.subEngine = null;
235
+ agent.subVpPersona = null;
236
+ agent.__driverStarted = false;
237
+ throw err;
238
+ }
222
239
  }
223
240
 
224
241
  /**
@@ -440,6 +440,14 @@ use it as the default workflow or call it repeatedly in a loop.`,
440
440
  }
441
441
  startSubAgent(agent, deps);
442
442
  } catch (err) {
443
+ if (agent.taskId && ctx?.taskManager?.completeTask) {
444
+ try {
445
+ ctx.taskManager.completeTask(callerScope.sessionId || ctx?.sessionId || 'default', agent.taskId, {
446
+ status: 'failed',
447
+ error: err && err.message ? err.message : String(err),
448
+ });
449
+ } catch { /* task terminal delivery is best-effort */ }
450
+ }
443
451
  agent.status = STATUS.FAILED;
444
452
  agent.error = err && err.message ? err.message : String(err);
445
453
  agent.diagnostics.push({ type: 'spawn_error', error: agent.error, at: Date.now() });
@@ -76,7 +76,7 @@ import { getAgentRegistry, agentBelongsToScope } from './tools/agent.js';
76
76
  import { isPromptableAgentStatus } from './sub-agent/status.js';
77
77
  import { perfNowMs, recordAgentPerfTrace } from './perf-trace.js';
78
78
  import { recordAgentSessionCreated, recordAgentTurn } from '../metrics.js';
79
- import { TASK_RESULT_DELIVERY, taskResultDeliveryFor } from './tasks/store.js';
79
+ import { TASK_RESULT_DELIVERY, isTerminalTaskStatus, taskResultDeliveryFor } from './tasks/store.js';
80
80
 
81
81
  const LEGACY_SKILL_COMMAND_PREFIX = 'skill:';
82
82
  const YEAFT_SKILL_COMMAND_PREFIX = 'yeaft-skills:';
@@ -487,6 +487,7 @@ function retireCachedVpEngine(key, {
487
487
  * onUnregister: (taskId: string, engine: import('./engine.js').Engine) => void,
488
488
  * onConsumed: (taskId: string, engine: import('./engine.js').Engine) => void,
489
489
  * onUndelivered: (taskId: string, delivery: object, engine: import('./engine.js').Engine) => void,
490
+ * onDeferred: (taskId: string, engine: import('./engine.js').Engine) => void,
490
491
  * }}
491
492
  */
492
493
  function buildAsyncTaskCoordinator() {
@@ -519,6 +520,22 @@ function buildAsyncTaskCoordinator() {
519
520
  taskStatus: delivery?.taskStatus,
520
521
  });
521
522
  },
523
+ onDeferred(taskId, engine) {
524
+ if (!engine?.ownsPendingAsyncTask?.(taskId)) return;
525
+ if (!deleteOwnerIfMatch(taskId, engine)) return;
526
+ const sessionId = engine?.sessionId || null;
527
+ const task = sessionId && session?.taskManager?.getTask?.(sessionId, taskId);
528
+ if (!task || !isTerminalTaskStatus(task.status)) return;
529
+ scheduleTaskResultRescue({
530
+ taskId,
531
+ sessionId: task.sessionId || sessionId,
532
+ vpId: task.ownerVpId || engine?.vpId || null,
533
+ threadId: task.source?.threadId || task.runtime?.threadId || engine?.currentThreadId || 'main',
534
+ content: formatTaskResultForVp(task),
535
+ taskKind: task.kind,
536
+ taskStatus: task.status,
537
+ });
538
+ },
522
539
  };
523
540
  }
524
541
  /**
@@ -4024,6 +4041,12 @@ function handleEngineEvent(event, hctx) {
4024
4041
  break;
4025
4042
 
4026
4043
  case 'async_task_wait_end':
4044
+ if (event.timedOut) {
4045
+ console.warn(
4046
+ '[Yeaft] same-turn async task wait timed out; continuing the VP turn and deferring task results:',
4047
+ Array.isArray(event.deferredTaskIds) ? event.deferredTaskIds : [],
4048
+ );
4049
+ }
4027
4050
  if (!event.aborted && typeof hctx.resetQueryTimer === 'function') hctx.resetQueryTimer();
4028
4051
  sendSessionEvent({
4029
4052
  type: 'vp_async_task_wait_end',
@@ -4032,6 +4055,8 @@ function handleEngineEvent(event, hctx) {
4032
4055
  loopNumber: event.loopNumber,
4033
4056
  aborted: Boolean(event.aborted),
4034
4057
  remainingTaskIds: Array.isArray(event.remainingTaskIds) ? event.remainingTaskIds : [],
4058
+ timedOut: Boolean(event.timedOut),
4059
+ deferredTaskIds: Array.isArray(event.deferredTaskIds) ? event.deferredTaskIds : [],
4035
4060
  ts: Date.now(),
4036
4061
  }, envelope);
4037
4062
  break;
@@ -7303,4 +7328,10 @@ export const __testHooks = {
7303
7328
  queuedTurnIds() {
7304
7329
  return Array.from(vpInboxes.values()).flatMap((inbox) => Array.isArray(inbox) ? inbox.map((entry) => entry.turnId) : []);
7305
7330
  },
7331
+ asyncTaskCoordinatorForTest() {
7332
+ return buildAsyncTaskCoordinator();
7333
+ },
7334
+ asyncTaskOwnerForTest(taskId) {
7335
+ return asyncTaskOwners.get(taskId) || null;
7336
+ },
7306
7337
  };