@yeaft/webchat-agent 0.1.1084 → 0.1.1086

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.1084",
3
+ "version": "0.1.1086",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/yeaft/engine.js CHANGED
@@ -22,7 +22,7 @@ import { promises as fsp } from 'fs';
22
22
  import { join, resolve as resolvePath } from 'path';
23
23
  import { buildSystemPrompt, buildWorkerPrompt } from './prompts.js';
24
24
  import { getRuntimePlatformInfo } from './runtime-platform.js';
25
- import { LLMContextError, LLMAbortError, LLMRateLimitError, LLMServerError } from './llm/adapter.js';
25
+ import { LLMContextError, LLMAbortError, LLMRateLimitError, LLMServerError, LLMStreamIdleTimeoutError } from './llm/adapter.js';
26
26
  import { runMemoryPreflow, buildRelevantScopes } from './sessions/pre-flow.js';
27
27
  import { readProjectDoc, pickProjectDocFile, DEFAULT_PROJECT_DOC_MAX_BYTES } from './sessions/project-doc.js';
28
28
  import { partitionMessages } from './compact/partition.js';
@@ -300,9 +300,10 @@ export function shouldAllowGroupReflection({
300
300
  * @typedef {{ type: 'consolidate', archivedCount: number, extractedCount: number }} ConsolidateEvent
301
301
  * @typedef {{ type: 'recall', entryCount: number, cached: boolean }} RecallEvent
302
302
  * @typedef {{ type: 'fallback', from: string, to: string, reason: string }} FallbackEvent
303
- * @typedef {{ type: 'llm_retry', attempt: number, maxRetries: number, delayMs: number, reason: 'rate_limit_retry_after'|'rate_limit_backoff'|'transient_backoff', errorName: string, statusCode: number|null, message: string }} LlmRetryEvent
303
+ * @typedef {{ type: 'llm_retry', attempt: number, maxRetries: number, delayMs: number, reason: 'rate_limit_retry_after'|'rate_limit_backoff'|'transient_backoff'|'stream_idle_timeout', errorName: string, statusCode: number|null, message: string }} LlmRetryEvent
304
+ * @typedef {{ type: 'error', error: Error, retryable: boolean, reason?: 'stream_idle_timeout', retryExhausted?: boolean }} ErrorEvent
304
305
  *
305
- * @typedef {import('./llm/adapter.js').StreamEvent | TurnStartEvent | TurnEndEvent | ToolStartEvent | ToolEndEvent | ConsolidateEvent | RecallEvent | FallbackEvent | LlmRetryEvent} EngineEvent
306
+ * @typedef {import('./llm/adapter.js').StreamEvent | TurnStartEvent | TurnEndEvent | ToolStartEvent | ToolEndEvent | ConsolidateEvent | RecallEvent | FallbackEvent | LlmRetryEvent | ErrorEvent} EngineEvent
306
307
  */
307
308
 
308
309
  // ─── Engine ──────────────────────────────────────────────────────
@@ -466,6 +467,50 @@ export class Engine {
466
467
  /** @type {Array<{content:string|Array, preview:string}>} */
467
468
  #pendingUserMessages = [];
468
469
 
470
+ /**
471
+ * Tasks (background bash, sub-agent spawns) that were launched DURING
472
+ * the currently-running query() and have NOT terminated yet. The query
473
+ * loop refuses to finalize end_turn while this set is non-empty — instead
474
+ * it parks on `#asyncTaskWaiters` until a terminal event or a new user
475
+ * append wakes it up. Cleared at the top of each query() and again in
476
+ * the finally block so a stale set never leaks across turns.
477
+ * @type {Set<string>}
478
+ */
479
+ #pendingAsyncTaskIds = new Set();
480
+
481
+ /**
482
+ * Terminal task events that arrived after their producing tool already
483
+ * returned. Each entry becomes a synthetic user message at the next
484
+ * adapter boundary. Format mirrors `#pendingUserMessages` so the same
485
+ * drain path can splice both into `conversationMessages`.
486
+ * @type {Array<{content:string|Array, preview:string, internal:boolean, taskId?:string}>}
487
+ */
488
+ #pendingTaskResultMessages = [];
489
+
490
+ /**
491
+ * Resolvers parked by the main loop while it waits for an async task to
492
+ * terminate (or a fresh user append to arrive). Wake order is FIFO; every
493
+ * resolver is invoked exactly once and the queue cleared, so a single
494
+ * task completion releases every waiter in the same engine and the loop
495
+ * decides on the next iteration whether to keep waiting.
496
+ * @type {Array<() => void>}
497
+ */
498
+ #asyncTaskWaiters = [];
499
+
500
+ /**
501
+ * External coordinator hooks. `getOrCreateVpEngine` (web-bridge.js)
502
+ * installs these so the bridge can route a `taskManager` `completed`
503
+ * event back to THIS engine while it's still running its query() — same
504
+ * turn, next adapter loop. Unset (null) in non-bridge contexts (tests,
505
+ * sub-agents without a bridge) — the engine then degrades to "no
506
+ * coordinator", which still works because tools call back through
507
+ * `toolCtx.registerAsyncTask` and the engine waits locally; web-bridge
508
+ * fallback (legacy `scheduleTaskResultReentry` → new turn) handles the
509
+ * post-run case.
510
+ * @type {{ onRegister?: (taskId:string, engine:Engine) => void, onUnregister?: (taskId:string) => void } | null}
511
+ */
512
+ #asyncTaskCoordinator = null;
513
+
469
514
  /**
470
515
  * Per-group "adjust has run at least once this engine lifetime" flag.
471
516
  * Keyed by sessionId (or 'default'). The first turn always runs adjust;
@@ -1042,6 +1087,13 @@ export class Engine {
1042
1087
  // can mark "after this batch, end the turn — do NOT call adapter
1043
1088
  // again". Honored at the top of the tool-loop continuation.
1044
1089
  requestEndTurn: vpCtx?.requestEndTurn,
1090
+ // Background-task ownership hook. Tools that produce a TaskManager
1091
+ // task (bash background, agent spawn) call this with the new
1092
+ // `task.id` so the engine keeps the current query parked at end_turn
1093
+ // until the task terminates — its result is then spliced into the
1094
+ // next adapter loop in the SAME turn. Tools that don't produce
1095
+ // async tasks ignore it.
1096
+ registerAsyncTask: (taskId) => this.#registerAsyncTask(taskId),
1045
1097
  // Sub-agent plumbing — Agent tool needs these to spawn a child
1046
1098
  // Engine that inherits the parent's adapter / stores / toolset.
1047
1099
  parentEngineDeps: {
@@ -1065,6 +1117,12 @@ export class Engine {
1065
1117
  // to. Null when the parent has no stats wired (e.g. tests).
1066
1118
  toolStats: this.#toolStats || null,
1067
1119
  taskManager: this.#taskManager || null,
1120
+ // Propagate the async-task coordinator so sub-agents launched
1121
+ // from this engine register their background tasks against the
1122
+ // SAME owner map the bridge uses. Without this, a sub-agent's
1123
+ // background bash terminal event would not find its engine and
1124
+ // would fall through to the legacy rescue path.
1125
+ asyncTaskCoordinator: this.#asyncTaskCoordinator || null,
1068
1126
  },
1069
1127
  };
1070
1128
  }
@@ -1394,16 +1452,28 @@ export class Engine {
1394
1452
  if (this.#pendingUserMessages.length > 0) {
1395
1453
  pending.push(...this.#pendingUserMessages.splice(0));
1396
1454
  }
1455
+ // Task-result re-entries flow through the same drain so the main loop
1456
+ // sees ONE append queue. Each entry carries `internal: true` so the
1457
+ // `user_append` event downstream is tagged correctly (UI hides the
1458
+ // bubble; persistence stamps role=assistant).
1459
+ if (this.#pendingTaskResultMessages.length > 0) {
1460
+ pending.push(...this.#pendingTaskResultMessages.splice(0));
1461
+ }
1397
1462
  return pending
1398
1463
  .map((item) => {
1399
- if (typeof item === 'string') return { content: item, preview: item };
1464
+ if (typeof item === 'string') return { content: item, preview: item, internal: false };
1400
1465
  if (!item || typeof item !== 'object') return null;
1401
1466
  const content = item.content ?? item.text;
1402
1467
  if (typeof content !== 'string' && !Array.isArray(content)) return null;
1403
1468
  const preview = typeof item.preview === 'string'
1404
1469
  ? item.preview
1405
1470
  : (typeof content === 'string' ? content : '[content blocks]');
1406
- return { content, preview };
1471
+ return {
1472
+ content,
1473
+ preview,
1474
+ internal: Boolean(item.internal),
1475
+ taskId: typeof item.taskId === 'string' ? item.taskId : undefined,
1476
+ };
1407
1477
  })
1408
1478
  .filter(Boolean);
1409
1479
  }
@@ -1512,6 +1582,27 @@ export class Engine {
1512
1582
  this.#abortReason = null;
1513
1583
  this.#currentThreadId = MAIN_THREAD_ID;
1514
1584
  this.#pendingUserMessages.length = 0;
1585
+ // Hand back any async tasks still on the books to the coordinator so
1586
+ // a late terminal event falls through to the legacy rescue path
1587
+ // (new turn) instead of being silently swallowed. The coordinator
1588
+ // is responsible for keeping its owner map in sync.
1589
+ if (this.#pendingAsyncTaskIds.size > 0) {
1590
+ const leftover = Array.from(this.#pendingAsyncTaskIds);
1591
+ this.#pendingAsyncTaskIds.clear();
1592
+ for (const tid of leftover) {
1593
+ try { this.#asyncTaskCoordinator?.onUnregister?.(tid); } catch { /* ignore */ }
1594
+ }
1595
+ }
1596
+ this.#pendingTaskResultMessages.length = 0;
1597
+ // Release any parked waiters so they don't pin a microtask after
1598
+ // query() returns. The loop has already exited so they're harmless,
1599
+ // but cleanup keeps the promise graph tight.
1600
+ if (this.#asyncTaskWaiters.length > 0) {
1601
+ const waiters = this.#asyncTaskWaiters.splice(0);
1602
+ for (const r of waiters) {
1603
+ try { r(); } catch { /* ignore */ }
1604
+ }
1605
+ }
1515
1606
  }
1516
1607
  }
1517
1608
 
@@ -2232,12 +2323,13 @@ export class Engine {
2232
2323
  }
2233
2324
  }
2234
2325
 
2235
- // ─── Rate-limit / transient retry ─────────────────
2326
+ // ─── Rate-limit / transient / stream-idle retry ───
2236
2327
  // Honour server-supplied Retry-After for 429/529; fall back to
2237
- // exponential backoff for 5xx and transport failures wrapped as
2238
- // LLMServerError. Counts against retryPolicy.maxRetries; on
2239
- // exhaustion we fall through to the fallback-model path (and
2240
- // ultimately the error event) without further waiting.
2328
+ // exponential backoff for 5xx, transport failures, and stream-idle
2329
+ // timeouts wrapped as LLMServerError. Counts against
2330
+ // retryPolicy.maxRetries; on exhaustion we fall through to the
2331
+ // fallback-model path (and ultimately the error event) without
2332
+ // further waiting.
2241
2333
  const isRateLimit = err instanceof LLMRateLimitError;
2242
2334
  const isTransient = err instanceof LLMServerError;
2243
2335
  if (isRateLimit || isTransient) {
@@ -2259,7 +2351,9 @@ export class Engine {
2259
2351
  reason = 'rate_limit_backoff';
2260
2352
  } else {
2261
2353
  delayMs = computeBackoffDelay(retryPolicy, consecutiveRetryableErrors - 1);
2262
- reason = 'transient_backoff';
2354
+ reason = err instanceof LLMStreamIdleTimeoutError
2355
+ ? 'stream_idle_timeout'
2356
+ : 'transient_backoff';
2263
2357
  }
2264
2358
  yield {
2265
2359
  type: 'llm_retry',
@@ -2286,7 +2380,7 @@ export class Engine {
2286
2380
  // ─── Fallback model ──────────────────────────────
2287
2381
  const fallbackModel = this.#config.fallbackModel;
2288
2382
  if (fallbackModel && fallbackModel !== currentModel &&
2289
- (err.name === 'LLMRateLimitError' || err.name === 'LLMServerError')) {
2383
+ (err instanceof LLMRateLimitError || err instanceof LLMServerError)) {
2290
2384
  yield { type: 'fallback', from: currentModel, to: fallbackModel, reason: err.message };
2291
2385
  currentModel = fallbackModel;
2292
2386
  consecutiveRetryableErrors = 0; // new model, fresh retry budget
@@ -2294,11 +2388,17 @@ export class Engine {
2294
2388
  continue; // retry with fallback model
2295
2389
  }
2296
2390
 
2297
- yield {
2391
+ const isRetryableError = err instanceof LLMRateLimitError || err instanceof LLMServerError;
2392
+ const errorEvent = {
2298
2393
  type: 'error',
2299
2394
  error: err,
2300
- retryable: err.name === 'LLMRateLimitError' || err.name === 'LLMServerError',
2395
+ retryable: isRetryableError,
2301
2396
  };
2397
+ if (err instanceof LLMStreamIdleTimeoutError) {
2398
+ errorEvent.reason = 'stream_idle_timeout';
2399
+ errorEvent.retryExhausted = consecutiveRetryableErrors >= retryPolicy.maxRetries;
2400
+ }
2401
+ yield errorEvent;
2302
2402
  yield { type: 'turn_end', turnNumber, stopReason: 'error', threadId };
2303
2403
  break;
2304
2404
  }
@@ -2460,6 +2560,80 @@ export class Engine {
2460
2560
  continue;
2461
2561
  }
2462
2562
 
2563
+ // If no tool calls, we're done — UNLESS we still own a pending
2564
+ // async task. The user-facing semantic: a turn that launched a
2565
+ // background bash / sub-agent stays "live" until those tasks
2566
+ // terminate (or the user appends, or abort). The model already
2567
+ // said end_turn; we just defer finalization by parking on the
2568
+ // wait queue, then splice the synthetic task-result message in
2569
+ // and run one more adapter loop. This matches the contract the
2570
+ // bridge documents for `formatTaskResultForVp`: "Consume it now:
2571
+ // tell the user the outcome or continue the work. Do not wait
2572
+ // for another user turn."
2573
+ if ((stopReason !== 'tool_use' || toolCalls.length === 0)
2574
+ && this.#pendingAsyncTaskIds.size > 0
2575
+ && !signal?.aborted) {
2576
+ // Drop into a wait loop. The loop wakes on (a) any task
2577
+ // terminal event delivered via `notifyAsyncTaskCompleted`, (b)
2578
+ // a fresh user append (which is honored as a higher priority
2579
+ // user input), or (c) abort. On wake we re-check: if either
2580
+ // queue has content, drain + splice + continue the outer loop.
2581
+ // If both queues are empty AND we still have pending tasks AND
2582
+ // we're not aborted, we just keep waiting. This is the only
2583
+ // place query() can block on something other than the LLM stream.
2584
+ yield {
2585
+ type: 'async_task_wait_start',
2586
+ turnId: queryTurnId,
2587
+ loopNumber: turnNumber,
2588
+ threadId,
2589
+ pendingTaskIds: Array.from(this.#pendingAsyncTaskIds),
2590
+ };
2591
+ // Race-safe wait: a `notifyAsyncTaskCompleted` callback that
2592
+ // arrives synchronously between `yield` and the `await` below
2593
+ // (e.g. the bridge handles a task event in the same microtask
2594
+ // as the wait_start event) will already have populated the
2595
+ // queues, so #waitForAsyncWake resolves immediately on its
2596
+ // fast-path check. Drain BEFORE deciding to continue so we
2597
+ // never "fall through" with content sitting in either queue.
2598
+ while (!signal?.aborted
2599
+ && this.#pendingTaskResultMessages.length === 0
2600
+ && this.#pendingUserMessages.length === 0) {
2601
+ if (this.#pendingAsyncTaskIds.size === 0) break;
2602
+ await this.#waitForAsyncWake(signal);
2603
+ }
2604
+ yield {
2605
+ type: 'async_task_wait_end',
2606
+ turnId: queryTurnId,
2607
+ loopNumber: turnNumber,
2608
+ threadId,
2609
+ aborted: Boolean(signal?.aborted),
2610
+ remainingTaskIds: Array.from(this.#pendingAsyncTaskIds),
2611
+ };
2612
+ if (!signal?.aborted) {
2613
+ const appendedAfterAsyncWait = this.#drainPendingUserMessages(drainPendingUserMessages);
2614
+ if (appendedAfterAsyncWait.length > 0) {
2615
+ for (const item of appendedAfterAsyncWait) {
2616
+ conversationMessages.push({ role: 'user', content: item.content });
2617
+ yield {
2618
+ type: 'user_append',
2619
+ turnId: queryTurnId,
2620
+ loopNumber: turnNumber,
2621
+ threadId,
2622
+ preview: String(item.preview || '').slice(0, 200),
2623
+ internal: Boolean(item.internal),
2624
+ taskId: typeof item.taskId === 'string' ? item.taskId : undefined,
2625
+ };
2626
+ }
2627
+ yield { type: 'turn_end', turnNumber, stopReason: 'async_task_continue', threadId };
2628
+ continue;
2629
+ }
2630
+ }
2631
+ // If we fall through here, either abort fired or the wait
2632
+ // exited with no payload (all tasks released themselves via
2633
+ // engine teardown / unregister with no notification). Drop into
2634
+ // the regular end_turn path.
2635
+ }
2636
+
2463
2637
  // If no tool calls, we're done
2464
2638
  if (stopReason !== 'tool_use' || toolCalls.length === 0) {
2465
2639
  if (pendingSubAgentNotifs.length > 0) {
@@ -3109,9 +3283,134 @@ export class Engine {
3109
3283
  if (typeof content === 'string' && !content.trim()) return false;
3110
3284
  const preview = typeof content === 'string' ? content : '[content blocks]';
3111
3285
  this.#pendingUserMessages.push({ content, preview });
3286
+ // Wake the async-task wait loop too. A user typing while the engine
3287
+ // is parked on a background task should release the loop immediately
3288
+ // so the user's words get spliced into the next iteration.
3289
+ this.#wakeAsyncTaskWaiters();
3290
+ return true;
3291
+ }
3292
+
3293
+ /**
3294
+ * Install the coordinator that lets an external dispatcher (web-bridge)
3295
+ * route a background task's terminal event back to THIS engine while
3296
+ * its query() is still running. Pass `null` to detach.
3297
+ * @param {{ onRegister?: (taskId:string, engine:Engine) => void, onUnregister?: (taskId:string) => void } | null} coord
3298
+ */
3299
+ setAsyncTaskCoordinator(coord) {
3300
+ this.#asyncTaskCoordinator = (coord && typeof coord === 'object') ? coord : null;
3301
+ }
3302
+
3303
+ /**
3304
+ * True iff the currently-running query is holding pending async tasks.
3305
+ * Used by external probes (web-bridge fallback dispatcher) to decide
3306
+ * whether a task terminal event should be injected into the same turn
3307
+ * or fall back to the legacy "open a new turn" rescue path.
3308
+ * @returns {boolean}
3309
+ */
3310
+ hasPendingAsyncTasks() {
3311
+ return this.#pendingAsyncTaskIds.size > 0;
3312
+ }
3313
+
3314
+ /**
3315
+ * True iff THIS engine has registered the given taskId as belonging to
3316
+ * the currently-running query. Web-bridge calls this before
3317
+ * `notifyAsyncTaskCompleted` so a terminal event for a task that wasn't
3318
+ * launched from this turn (or whose engine already finished) falls
3319
+ * through to the legacy rescue path.
3320
+ * @param {string} taskId
3321
+ * @returns {boolean}
3322
+ */
3323
+ ownsPendingAsyncTask(taskId) {
3324
+ return typeof taskId === 'string' && this.#pendingAsyncTaskIds.has(taskId);
3325
+ }
3326
+
3327
+ /**
3328
+ * Deliver a terminal task event into the currently-running query. Drops
3329
+ * the task from the pending set, queues a synthetic user message with
3330
+ * the rendered task result, and wakes the wait loop. Returns true iff
3331
+ * the engine accepted ownership (the bridge should NOT fall back to the
3332
+ * legacy rescue path). Returns false when the engine was never holding
3333
+ * this taskId — caller should fall through to its rescue path.
3334
+ *
3335
+ * @param {string} taskId
3336
+ * @param {string|Array} content — pre-formatted task result body
3337
+ * @param {{ preview?: string }} [opts]
3338
+ * @returns {boolean}
3339
+ */
3340
+ notifyAsyncTaskCompleted(taskId, content, opts = {}) {
3341
+ if (!this.ownsPendingAsyncTask(taskId)) return false;
3342
+ if (typeof content !== 'string' && !Array.isArray(content)) return false;
3343
+ if (typeof content === 'string' && !content.trim()) return false;
3344
+ // Defensive: an empty content-block array would splice as a wire-valid
3345
+ // but semantically empty user message, which the adapter would happily
3346
+ // forward and bill for. Production callers (formatTaskResultForVp)
3347
+ // always emit a non-empty string today; this guards future refactors.
3348
+ if (Array.isArray(content) && content.length === 0) return false;
3349
+ this.#pendingAsyncTaskIds.delete(taskId);
3350
+ try { this.#asyncTaskCoordinator?.onUnregister?.(taskId); } catch { /* coord must not throw into engine */ }
3351
+ const preview = typeof opts.preview === 'string'
3352
+ ? opts.preview
3353
+ : (typeof content === 'string' ? content.slice(0, 200) : '[task result]');
3354
+ this.#pendingTaskResultMessages.push({
3355
+ content,
3356
+ preview,
3357
+ internal: true,
3358
+ taskId,
3359
+ });
3360
+ this.#wakeAsyncTaskWaiters();
3112
3361
  return true;
3113
3362
  }
3114
3363
 
3364
+ /**
3365
+ * Register a background task as belonging to the current query. Called
3366
+ * from tools (bash background, agent spawn) via `toolCtx.registerAsyncTask`.
3367
+ * @param {string} taskId
3368
+ * @returns {void}
3369
+ */
3370
+ #registerAsyncTask(taskId) {
3371
+ if (typeof taskId !== 'string' || !taskId) return;
3372
+ this.#pendingAsyncTaskIds.add(taskId);
3373
+ try { this.#asyncTaskCoordinator?.onRegister?.(taskId, this); } catch { /* coord must not throw into tools */ }
3374
+ }
3375
+
3376
+ #wakeAsyncTaskWaiters() {
3377
+ if (this.#asyncTaskWaiters.length === 0) return;
3378
+ const waiters = this.#asyncTaskWaiters.splice(0);
3379
+ for (const resolve of waiters) {
3380
+ try { resolve(); } catch { /* never break the loop on a stray callback */ }
3381
+ }
3382
+ }
3383
+
3384
+ /**
3385
+ * Wait for *any* of: an async task terminal event, a fresh user append,
3386
+ * or signal abort. Resolves immediately if any of those is already
3387
+ * pending. The loop re-evaluates conditions on wake — multiple
3388
+ * concurrent tasks all wake the same waiter once, then the loop drains
3389
+ * everything in one iteration and decides whether to keep waiting.
3390
+ * @param {AbortSignal|null|undefined} signal
3391
+ * @returns {Promise<void>}
3392
+ */
3393
+ #waitForAsyncWake(signal) {
3394
+ return new Promise((resolve) => {
3395
+ // Fast paths — anything already pending releases instantly. This is
3396
+ // the common case when a task finished between adapter loops.
3397
+ if (this.#pendingTaskResultMessages.length > 0) return resolve();
3398
+ if (this.#pendingUserMessages.length > 0) return resolve();
3399
+ if (signal?.aborted) return resolve();
3400
+ this.#asyncTaskWaiters.push(resolve);
3401
+ if (signal && typeof signal.addEventListener === 'function') {
3402
+ // Best-effort abort wakeup; the loop re-checks signal.aborted.
3403
+ const onAbort = () => {
3404
+ // Splice the resolver out of the wait queue so it can't double-fire.
3405
+ const idx = this.#asyncTaskWaiters.indexOf(resolve);
3406
+ if (idx >= 0) this.#asyncTaskWaiters.splice(idx, 1);
3407
+ try { resolve(); } catch { /* ignore */ }
3408
+ };
3409
+ try { signal.addEventListener('abort', onAbort, { once: true }); } catch { /* old runtimes */ }
3410
+ }
3411
+ });
3412
+ }
3413
+
3115
3414
  /** @returns {string|null} */
3116
3415
  get yeaftDir() { return this.#yeaftDir; }
3117
3416
 
@@ -156,6 +156,12 @@ export function startSubAgent(agent, deps = {}) {
156
156
  toolStats: deps.toolStats || null,
157
157
  taskManager: deps.taskManager || null,
158
158
  });
159
+ // Same-turn async-task plumbing: inherit the parent's coordinator so a
160
+ // background bash launched FROM this sub-agent registers itself against
161
+ // the shared owner map and its terminal event reaches the sub-engine.
162
+ if (deps.asyncTaskCoordinator && typeof subEngine.setAsyncTaskCoordinator === 'function') {
163
+ subEngine.setAsyncTaskCoordinator(deps.asyncTaskCoordinator);
164
+ }
159
165
 
160
166
  agent.subEngine = subEngine;
161
167
  agent.engineMessages = agent.engineMessages || [];
@@ -427,7 +427,14 @@ use it as the default workflow or call it repeatedly in a loop.`,
427
427
  runtime: { subAgentId: agentId, name, cwd: agent.cwd },
428
428
  source: { threadId: callerScope.parentThreadId || ctx?.threadId || 'main' },
429
429
  });
430
- if (task?.id) agent.taskId = task.id;
430
+ if (task?.id) {
431
+ agent.taskId = task.id;
432
+ // Same-turn parking — see tools/bash.js for the contract. The
433
+ // sub-agent runs in its own engine but reports completion
434
+ // through the same TaskManager event, so the spawning turn
435
+ // stays parked until the sub-agent finishes.
436
+ try { ctx.registerAsyncTask?.(task.id); } catch { /* coord errors must not block spawn */ }
437
+ }
431
438
  startSubAgent(agent, deps);
432
439
  } catch (err) {
433
440
  agent.status = STATUS.FAILED;
@@ -246,6 +246,12 @@ Guidelines:
246
246
  threadId: ctx.threadId || 'main',
247
247
  },
248
248
  });
249
+ // Same-turn parking: tell the engine "this turn has an async
250
+ // task in flight". The engine refuses to finalize end_turn
251
+ // while the set is non-empty and will splice the task result
252
+ // into the next adapter loop when it terminates. No-op when
253
+ // the engine didn't wire the hook (legacy callers / tests).
254
+ try { ctx.registerAsyncTask?.(task.id); } catch { /* never block tool return on coord errors */ }
249
255
  return `Started background task ${task.id}.\nStatus: ${task.status}\nLog: ${task.log?.path || ''}\nUse ListTasks, ReadTaskLog, or CancelTask to inspect or control it.`;
250
256
  } catch (err) {
251
257
  return JSON.stringify({ error: err?.message || String(err) });
@@ -258,6 +258,42 @@ const vpDrivers = new Map();
258
258
  const vpEngines = new Map();
259
259
  /** @type {Map<string, AbortController>} */
260
260
  const vpAborts = new Map();
261
+
262
+ /**
263
+ * Owner index for background tasks currently parked on a running engine.
264
+ * Populated by the per-engine async-task coordinator at register time;
265
+ * cleared at notify time or when the engine teardown unregisters whatever
266
+ * it didn't get to deliver. Used by `scheduleTaskResultReentry` to pick
267
+ * "same-turn injection" over the legacy "new turn" rescue path when the
268
+ * engine is still live.
269
+ * @type {Map<string, import('./engine.js').Engine>}
270
+ */
271
+ const asyncTaskOwners = new Map();
272
+
273
+ /**
274
+ * Build a coordinator for a freshly-constructed engine. The coordinator
275
+ * keeps `asyncTaskOwners` in sync so a `taskManager` `completed` event
276
+ * can find the engine that launched it in O(1).
277
+ *
278
+ * Defined as a factory (not a single shared object) so each engine's
279
+ * `onRegister` callback closes over its own engine reference — sub-agents
280
+ * inherit a coordinator that still associates their tasks with the
281
+ * sub-engine, not the parent.
282
+ *
283
+ * @returns {{ onRegister: (taskId: string, engine: import('./engine.js').Engine) => void, onUnregister: (taskId: string) => void }}
284
+ */
285
+ function buildAsyncTaskCoordinator() {
286
+ return {
287
+ onRegister(taskId, engine) {
288
+ if (typeof taskId !== 'string' || !taskId) return;
289
+ asyncTaskOwners.set(taskId, engine);
290
+ },
291
+ onUnregister(taskId) {
292
+ if (typeof taskId !== 'string' || !taskId) return;
293
+ asyncTaskOwners.delete(taskId);
294
+ },
295
+ };
296
+ }
261
297
  /**
262
298
  * Per-(sessionId, vpId) current TodoWrite list. Each VP in a group keeps
263
299
  * its own todo state so two VPs in the same group can independently
@@ -956,6 +992,15 @@ function getOrCreateVpEngine(sessionId, vpId, threadId = 'main') {
956
992
  sessionId,
957
993
  vpId,
958
994
  });
995
+ // Install the async-task coordinator so background tasks launched
996
+ // from this engine register against the shared owner map and
997
+ // `scheduleTaskResultReentry` can deliver terminal events back into
998
+ // the same query() instead of opening a fresh turn.
999
+ try {
1000
+ if (typeof eng.setAsyncTaskCoordinator === 'function') {
1001
+ eng.setAsyncTaskCoordinator(buildAsyncTaskCoordinator());
1002
+ }
1003
+ } catch { /* coordinator is best-effort plumbing, never block engine creation */ }
959
1004
  vpEngines.set(key, eng);
960
1005
  return eng;
961
1006
  }
@@ -1053,6 +1098,36 @@ function scheduleTaskResultReentry(event) {
1053
1098
  const vpId = task.ownerVpId || null;
1054
1099
  if (!sessionId || !vpId) return;
1055
1100
  const threadId = task.source?.threadId || task.runtime?.threadId || 'main';
1101
+ const formatted = formatTaskResultForVp(task);
1102
+
1103
+ // Same-turn fast path: when the engine that launched this task is
1104
+ // still running its query() AND has parked on the wait queue, hand
1105
+ // the result straight in — it splices into the very next adapter
1106
+ // loop with no new turn / new VP envelope. Falls through to the
1107
+ // legacy "open a new turn" rescue path when:
1108
+ // - the engine already finished (typical orphan / late completion),
1109
+ // - the engine was torn down between register and complete, or
1110
+ // - the task wasn't registered with the engine in the first place
1111
+ // (e.g. legacy `taskManager.startTask` callers that bypass tools).
1112
+ const ownerEngine = asyncTaskOwners.get(task.id);
1113
+ if (ownerEngine
1114
+ && typeof ownerEngine.ownsPendingAsyncTask === 'function'
1115
+ && ownerEngine.ownsPendingAsyncTask(task.id)
1116
+ && typeof ownerEngine.notifyAsyncTaskCompleted === 'function') {
1117
+ try {
1118
+ const accepted = ownerEngine.notifyAsyncTaskCompleted(task.id, formatted, {
1119
+ preview: `task ${task.kind || 'tool'} ${task.status}`,
1120
+ });
1121
+ if (accepted) {
1122
+ asyncTaskOwners.delete(task.id);
1123
+ return;
1124
+ }
1125
+ } catch {
1126
+ // Same-turn delivery is best-effort. Fall through to the legacy
1127
+ // rescue path so we never drop a terminal event on the floor.
1128
+ }
1129
+ }
1130
+
1056
1131
  const msgId = `task_result_${Date.now().toString(36)}_${randomUUID().slice(0, 8)}`;
1057
1132
  queueMicrotask(() => {
1058
1133
  enqueueForVp(sessionId, vpId, {
@@ -1064,7 +1139,7 @@ function scheduleTaskResultReentry(event) {
1064
1139
  id: msgId,
1065
1140
  from: 'tool',
1066
1141
  role: 'assistant',
1067
- text: formatTaskResultForVp(task),
1142
+ text: formatted,
1068
1143
  meta: {
1069
1144
  injectedBy: 'task_result',
1070
1145
  taskId: task.id,
@@ -1399,6 +1474,7 @@ export async function __testResetVpState() {
1399
1474
  vpInboxes.clear();
1400
1475
  vpDrivers.clear();
1401
1476
  vpEngines.clear();
1477
+ asyncTaskOwners.clear();
1402
1478
  vpAborts.clear();
1403
1479
  sessionContexts.clear();
1404
1480
  vpCurrentTodos.clear();
@@ -2345,8 +2421,9 @@ function handleEngineEvent(event, hctx) {
2345
2421
 
2346
2422
  case 'llm_retry':
2347
2423
  // Engine paused before re-issuing the same turn because the LLM
2348
- // returned a retryable error (rate limit / 5xx / transient network).
2349
- // Surface to the client so the UI can show "retrying in Xs (1/3)"
2424
+ // returned a retryable error (rate limit / 5xx / transient network /
2425
+ // stream idle timeout). Surface to the client so the UI can show
2426
+ // "retrying in Xs (1/3)"
2350
2427
  // instead of looking frozen mid-turn.
2351
2428
  sendSessionEvent({
2352
2429
  type: 'llm_retry',
@@ -2456,6 +2533,37 @@ function handleEngineEvent(event, hctx) {
2456
2533
  }, envelope);
2457
2534
  break;
2458
2535
 
2536
+ // Same-turn async-task wait. Engine parks at end_turn while a
2537
+ // background bash / sub-agent is still running and re-enters the
2538
+ // same turn when the terminal event arrives (see engine.js
2539
+ // `#runQuery` wait block). Bridge forwards both edges so the debug
2540
+ // panel (and any other in-process subscriber) can render the park
2541
+ // window with the live list of pending taskIds. Wire types stay
2542
+ // namespaced under `vp_async_task_*` to match the existing
2543
+ // `vp_thread_*` / `vp_typing_*` event family.
2544
+ case 'async_task_wait_start':
2545
+ sendSessionEvent({
2546
+ type: 'vp_async_task_wait_start',
2547
+ turnId: event.turnId,
2548
+ threadId: event.threadId,
2549
+ loopNumber: event.loopNumber,
2550
+ pendingTaskIds: Array.isArray(event.pendingTaskIds) ? event.pendingTaskIds : [],
2551
+ ts: Date.now(),
2552
+ }, envelope);
2553
+ break;
2554
+
2555
+ case 'async_task_wait_end':
2556
+ sendSessionEvent({
2557
+ type: 'vp_async_task_wait_end',
2558
+ turnId: event.turnId,
2559
+ threadId: event.threadId,
2560
+ loopNumber: event.loopNumber,
2561
+ aborted: Boolean(event.aborted),
2562
+ remainingTaskIds: Array.isArray(event.remainingTaskIds) ? event.remainingTaskIds : [],
2563
+ ts: Date.now(),
2564
+ }, envelope);
2565
+ break;
2566
+
2459
2567
  case 'loop':
2460
2568
  // feat-6af5f9f1 PR B: replaces the old `debug_turn` event. Same
2461
2569
  // payload shape plus turnId + loopNumber + usage.totalTokens.
@@ -2482,6 +2590,14 @@ function handleEngineEvent(event, hctx) {
2482
2590
 
2483
2591
  case 'error': {
2484
2592
  const errMsg = event.error?.message || 'Unknown error';
2593
+ sendSessionEvent({
2594
+ type: 'error',
2595
+ message: errMsg,
2596
+ errorName: event.error?.name || null,
2597
+ retryable: !!event.retryable,
2598
+ ...(event.reason ? { reason: event.reason } : {}),
2599
+ ...(event.retryExhausted !== undefined ? { retryExhausted: !!event.retryExhausted } : {}),
2600
+ }, envelope);
2485
2601
  if (isPermissionErrorMsg(errMsg)) {
2486
2602
  if (!_permissionDiagnosticSent) {
2487
2603
  _permissionDiagnosticSent = true;
@@ -4160,6 +4276,7 @@ export function handleYeaftModelSwitch(msg) {
4160
4276
  // Engines would otherwise keep the old effective config and drop newly
4161
4277
  // selected effort values until process restart.
4162
4278
  vpEngines.clear();
4279
+ asyncTaskOwners.clear();
4163
4280
 
4164
4281
  sendSessionEvent({
4165
4282
  type: 'model_switched',
@@ -4501,6 +4618,7 @@ export async function resetYeaftSession() {
4501
4618
  vpInboxes.clear();
4502
4619
  vpDrivers.clear();
4503
4620
  vpEngines.clear();
4621
+ asyncTaskOwners.clear();
4504
4622
  sessionContexts.clear();
4505
4623
  vpCurrentTodos.clear();
4506
4624
  threadClassifier = defaultClassifyThread;