@yeaft/webchat-agent 1.0.379 → 1.0.381

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.
@@ -175,12 +175,24 @@ function modelRefsEquivalent(left, right) {
175
175
  function resolveLiveSessionConfig(baseConfig, sessionId, options = {}) {
176
176
  const configRoot = baseConfig?.dir || liveConfigRoot();
177
177
  const sessionConfig = normalizeSessionConfig(configRoot, sessionId, baseConfig, options);
178
- return resolveSessionConfig(baseConfig, sessionConfig);
178
+ const resolved = resolveSessionConfig(baseConfig, sessionConfig);
179
+ // Session config lives in the agent-local root. `resolveSessionConfig()`
180
+ // intentionally returns a fresh object without its storage hint, so restore
181
+ // it for the next cached-engine lookup.
182
+ return configRoot && !resolved.dir ? { ...resolved, dir: configRoot } : resolved;
179
183
  }
180
184
 
181
185
  let sessionConfigRefreshRevision = 0;
182
186
 
183
- /** Reload the Agent-owned config and install it into every live Engine. */
187
+ /**
188
+ * Reload the Agent-owned config and install it into every live Engine.
189
+ *
190
+ * This deliberately mutates only runtime snapshots. Model/config saves must
191
+ * not retire cached engines, clear task owners, invalidate the coordinator, or
192
+ * abort a request that has already started. Engine.query() captures its LLM
193
+ * request values at each loop boundary, so an active stream completes with its
194
+ * original config and a following tool loop uses the published snapshot.
195
+ */
184
196
  export async function refreshLiveSessionConfig(options = {}) {
185
197
  sessionConfigRefreshRevision += 1;
186
198
  if (!session && sessionLoadPromise) {
@@ -591,14 +603,17 @@ const vpCurrentTodos = new Map();
591
603
  * that nobody consumes — exactly the pre-707 bug).
592
604
  *
593
605
  * Purge sites:
594
- * - `invalidateGroupContext(sessionId)` — called from every group CRUD
595
- * handler that mutates roster / meta / lifecycle state on disk
596
- * (rename, update announcement, archive, delete, add/remove member,
597
- * set default VP).
598
- * - `handleYeaftSessionSend` invalidates inline when its own
599
- * auto-add / default-VP-heal pass mutated the roster.
606
+ * - `invalidateGroupContext(sessionId)` — called from Session CRUD handlers
607
+ * that mutate roster / metadata / lifecycle state on disk (rename, update
608
+ * announcement, archive, delete, add/remove member, set default VP).
609
+ * - `handleYeaftSessionSend` — invalidates inline when its own auto-add /
610
+ * default-VP-heal pass mutated the roster.
600
611
  * - `resetYeaftSession` and `__testResetVpState` clear the whole map.
601
612
  *
613
+ * Model/config saves deliberately do not purge this map: they publish a new
614
+ * in-memory runtime snapshot and the active engine adopts it at its next LLM
615
+ * loop boundary without aborting the request already in flight.
616
+ *
602
617
  * @type {Map<string, { coord: ReturnType<typeof createCoordinator>,
603
618
  * router: ReturnType<typeof createRouter>,
604
619
  * sessionHandle: object }>}
@@ -1803,8 +1818,16 @@ function getOrCreateVpEngine(sessionId, vpId, threadId = 'main') {
1803
1818
  const effectiveConfig = resolveLiveSessionConfig(session.config, sessionId);
1804
1819
  const configKey = engineConfigKey(effectiveConfig);
1805
1820
  let eng = vpEngines.get(key);
1806
- if (eng && vpEngineConfigKeys.get(key) === configKey) return eng;
1807
- if (eng) retireCachedVpEngine(key, { reason: 'config_changed', rescue: true, expectedEngine: eng });
1821
+ if (eng) {
1822
+ // Config changes are snapshots, not a lifecycle boundary. The save handler
1823
+ // publishes the snapshot to every cached engine. The next query loop reads
1824
+ // it before invoking its adapter; no engine replacement or abort needed.
1825
+ if (vpEngineConfigKeys.get(key) !== configKey) {
1826
+ eng.refreshConfig?.(effectiveConfig);
1827
+ vpEngineConfigKeys.set(key, configKey);
1828
+ }
1829
+ return eng;
1830
+ }
1808
1831
  eng = new Engine({
1809
1832
  adapter: session.adapter,
1810
1833
  trace: session.trace,
@@ -3416,12 +3439,14 @@ export function handleYeaftUpdateSession(msg) {
3416
3439
  }
3417
3440
 
3418
3441
  /**
3419
- * Persist the model selected in the group conversation header. Cache invalidation:
3420
- * drop every cached Engine whose key starts with `${sessionId}::` so the
3421
- * next turn picks up the new model. The group meta itself is untouched.
3442
+ * Persist the model selected in the Session conversation header.
3443
+ *
3444
+ * Cached engines remain alive. This handler publishes their updated effective
3445
+ * config; `Engine.refreshConfig()` applies it at the next LLM loop boundary.
3446
+ * The current stream and its AbortController are untouched.
3422
3447
  *
3423
3448
  * Payload: { sessionId, requestId, config: { model?: string|null } }
3424
- * - `model: ''` or `null` clears the selected group model (falls back to user default).
3449
+ * - `model: ''` or `null` clears the selected Session model (falls back to user default).
3425
3450
  */
3426
3451
  export function handleYeaftUpdateSessionConfig(msg) {
3427
3452
  const requestId = msg && msg.requestId;
@@ -3433,15 +3458,23 @@ export function handleYeaftUpdateSessionConfig(msg) {
3433
3458
  if (!partial) throw new SessionConfigError('invalid_patch', 'config object required');
3434
3459
  const yeaftDir = ctx.CONFIG?.yeaftDir;
3435
3460
  const savedConfig = updateSessionConfig(yeaftDir, sessionId, partial);
3436
- // Retire cached engines so accepted terminal task results are rescued
3437
- // before the next VP turn rebuilds with the new model.
3461
+ const effectiveBaseConfig = session?.config || loadConfig({ dir: yeaftDir });
3462
+ // The write above is the source of truth. Merge it directly instead of
3463
+ // reopening the file so cached engines cannot observe an unrelated stale
3464
+ // read between persistence and publication.
3465
+ const sessionConfig = resolveSessionConfig(effectiveBaseConfig, savedConfig);
3466
+ const publishedSessionConfig = sessionConfig.dir || !effectiveBaseConfig?.dir
3467
+ ? sessionConfig
3468
+ : { ...sessionConfig, dir: effectiveBaseConfig.dir };
3438
3469
  const prefix = `${sessionId}::`;
3439
- for (const k of Array.from(vpEngines.keys())) {
3440
- if (k.startsWith(prefix)) {
3441
- retireCachedVpEngine(k, { reason: 'session_config_changed', rescue: true });
3442
- }
3470
+ for (const [key, engine] of vpEngines) {
3471
+ if (!key.startsWith(prefix)) continue;
3472
+ engine.refreshConfig?.(publishedSessionConfig);
3473
+ vpEngineConfigKeys.set(key, engineConfigKey(publishedSessionConfig));
3443
3474
  }
3444
- invalidateGroupContext(sessionId);
3475
+ // Do not invalidate the Session coordinator or abort active VP turns.
3476
+ // The next adapter loop sees this config; the stream already underway
3477
+ // completes with the values captured for that request.
3445
3478
  sendSessionCrudResult({ op: 'update_config', requestId, ok: true, sessionId, config: savedConfig });
3446
3479
  sendSessionSnapshotBroadcast();
3447
3480
  } catch (err) {
@@ -1022,7 +1022,7 @@ export class WorkItemRunner {
1022
1022
  }
1023
1023
  }
1024
1024
 
1025
- async run({ workItem, action, run, signal, ownerBootId, onProgress, registerProgressReader, registerInputWake }) {
1025
+ async run({ workItem, action, run, signal, ownerBootId, onProgress, registerProgressReader, registerInputWake, onEngineEvent = null }) {
1026
1026
  const runtime = await this.runtimeProvider();
1027
1027
  const currentSettings = ['ai', 'coordinator'].includes(workItem?.workflowSnapshot?.planningMode)
1028
1028
  && this.policyProvider ? await this.policyProvider() : null;
@@ -1307,6 +1307,7 @@ export class WorkItemRunner {
1307
1307
  }
1308
1308
  return accepted;
1309
1309
  };
1310
+ let activeProviderRequest = null;
1310
1311
  const prepareProviderRequest = ({ entries, system, messages, model }) => {
1311
1312
  const durableEntries = entries
1312
1313
  .filter(entry => entry?.durableInputId)
@@ -1318,6 +1319,7 @@ export class WorkItemRunner {
1318
1319
  { requestBody, dispatchCapability: 'unknown' },
1319
1320
  );
1320
1321
  if (!turn) throw new Error('Work Center could not persist the next provider turn');
1322
+ activeProviderRequest = turn;
1321
1323
  return turn;
1322
1324
  };
1323
1325
  const startProviderRequest = turn => {
@@ -1330,10 +1332,12 @@ export class WorkItemRunner {
1330
1332
  if (!this.store.consumeEngineTurn?.(turn.id, ownerBootId, run.leaseEpoch, result)) {
1331
1333
  throw new Error('Work Center provider response lost its EngineTurn fence');
1332
1334
  }
1335
+ if (activeProviderRequest?.id === turn.id) activeProviderRequest = null;
1333
1336
  };
1334
1337
  const failProviderRequest = (turn, error) => {
1335
1338
  if (!turn) return;
1336
1339
  const failure = this.store.failEngineTurn?.(turn.id, ownerBootId, run.leaseEpoch, error);
1340
+ if (activeProviderRequest?.id === turn.id) activeProviderRequest = null;
1337
1341
  if (failure && failure.allowRetry === false) {
1338
1342
  error.retryable = false;
1339
1343
  error.workItemFailureKind = 'provider_dispatch_unknown';
@@ -1351,7 +1355,7 @@ export class WorkItemRunner {
1351
1355
  const promptParts = attachmentContext.promptParts.length > 0
1352
1356
  ? [{ type: 'text', text: prompt }, ...attachmentContext.promptParts]
1353
1357
  : null;
1354
- for await (const event of engine.query({
1358
+ const query = engine.query({
1355
1359
  prompt,
1356
1360
  promptParts,
1357
1361
  messages: [],
@@ -1373,7 +1377,25 @@ export class WorkItemRunner {
1373
1377
  ),
1374
1378
 
1375
1379
  collabToolPolicy: 'single-vp',
1376
- })) {
1380
+ });
1381
+ const iterator = query[Symbol.asyncIterator]();
1382
+ let stoppedByEngineEvent = false;
1383
+ let stoppedAfterDispatch = false;
1384
+ while (true) {
1385
+ const step = await iterator.next();
1386
+ if (step.done) break;
1387
+ const event = step.value;
1388
+ const control = await onEngineEvent?.(event, { iterator, engine, query });
1389
+ if (control?.stop === true) {
1390
+ // The durable in-flight turn, not an event label, is the authoritative
1391
+ // dispatch boundary. `user_append` and other pre-request events can
1392
+ // precede turn_start; only an active EngineTurn is unsafe to replay.
1393
+ stoppedAfterDispatch = Boolean(activeProviderRequest);
1394
+ if (stoppedAfterDispatch) engine.abort?.('work_item_consumer_stopped_after_dispatch');
1395
+ await iterator.return();
1396
+ stoppedByEngineEvent = true;
1397
+ break;
1398
+ }
1377
1399
  if (event?.type === 'loop') {
1378
1400
  loopCount += 1;
1379
1401
  this.store.appendRunLoop?.(run.id, ownerBootId, run.leaseEpoch, {
@@ -1405,6 +1427,32 @@ export class WorkItemRunner {
1405
1427
  // preserve Work Center's historical rejection semantics so Run fencing,
1406
1428
  // hazardous side-effect handling, and retry policy still see the failure.
1407
1429
  if (terminalEngineError) throw terminalEngineError;
1430
+ if (stoppedByEngineEvent) {
1431
+ const stopped = new Error(stoppedAfterDispatch
1432
+ ? 'Work Center Engine consumer stopped after provider dispatch'
1433
+ : 'Work Center Engine consumer stopped before provider dispatch');
1434
+ stopped.name = 'WorkCenterEngineStoppedError';
1435
+ if (stoppedAfterDispatch) {
1436
+ // A visible provider event proves dispatch happened. The Engine's
1437
+ // iterator close cannot safely replay that request, so terminally
1438
+ // fence its durable turn before the watcher sees the failure.
1439
+ const failed = this.store.failEngineTurn?.(
1440
+ activeProviderRequest?.id,
1441
+ ownerBootId,
1442
+ run.leaseEpoch,
1443
+ stopped,
1444
+ );
1445
+ activeProviderRequest = null;
1446
+ stopped.retryable = false;
1447
+ stopped.workItemFailureKind = failed?.status === 'unknown'
1448
+ ? 'provider_dispatch_unknown'
1449
+ : 'system_blocked';
1450
+ stopped.workItemFailureCode = failed?.status === 'unknown'
1451
+ ? 'engine_turn_dispatch_unknown'
1452
+ : 'engine_turn_stop_failed';
1453
+ }
1454
+ throw stopped;
1455
+ }
1408
1456
  } catch (error) {
1409
1457
  error.workItemExecutionStats = currentProgress();
1410
1458
  throw error;
@@ -2128,6 +2128,7 @@ export class WorkItemStore {
2128
2128
  const turn = this.getEngineTurn(turnId);
2129
2129
  if (!turn || turn.status !== 'dispatching' || turn.ownerBootId !== ownerBootId
2130
2130
  || turn.leaseEpoch !== leaseEpoch) return false;
2131
+ if (!this.#activeRunRow(turn.runId, ownerBootId, leaseEpoch, true)) return false;
2131
2132
  const now = this.now();
2132
2133
  const response = {
2133
2134
  text: String(result.responseText || ''),
@@ -2161,7 +2162,8 @@ export class WorkItemStore {
2161
2162
  failEngineTurn(turnId, ownerBootId, leaseEpoch, error) {
2162
2163
  return withTransaction(this.db, () => {
2163
2164
  const turn = this.getEngineTurn(turnId);
2164
- if (!turn || turn.ownerBootId !== ownerBootId || turn.leaseEpoch !== leaseEpoch) {
2165
+ if (!turn || turn.ownerBootId !== ownerBootId || turn.leaseEpoch !== leaseEpoch
2166
+ || !this.#activeRunRow(turn.runId, ownerBootId, leaseEpoch, true)) {
2165
2167
  return { allowRetry: false, status: 'stale' };
2166
2168
  }
2167
2169
  if (turn.status === 'prepared') return { allowRetry: true, status: 'prepared' };