@yeaft/webchat-agent 1.0.252 → 1.0.253

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.
@@ -894,7 +894,7 @@ export function projectWorkItemDetail(detail, options = {}) {
894
894
  status: ['thinking', 'completed', 'failed'].includes(message.status) ? message.status : 'completed',
895
895
  error: truncateUtf8(message.error || '', MAX_ACTION_DIAGNOSTIC_CHARS) || null,
896
896
  decision: message.decision && typeof message.decision === 'object' ? {
897
- kind: ['answer', 'guide_actions', 'replan'].includes(message.decision.kind)
897
+ kind: ['answer', 'guide_actions', 'replan', 'request_human'].includes(message.decision.kind)
898
898
  ? message.decision.kind : null,
899
899
  reason: truncateUtf8(message.decision.reason || '', MAX_ACTION_DIAGNOSTIC_CHARS),
900
900
  changedContract: message.decision.changedContract === true,
@@ -107,6 +107,13 @@ export class WorkCenterService {
107
107
  });
108
108
  this.coordinator = options.coordinator || null;
109
109
  this.onEvent = typeof options.onEvent === 'function' ? options.onEvent : () => {};
110
+ this.recoveryTasks = new Map();
111
+ this.recoveryQueue = new Map();
112
+ this.recoveryPollIntervalMs = Number(options.pollIntervalMs) > 0
113
+ ? Number(options.pollIntervalMs)
114
+ : 2_000;
115
+ this.recoveryTimer = null;
116
+ this.shuttingDown = false;
110
117
  this.watcher = new WorkItemWatcher({
111
118
  store: this.store,
112
119
  controller: this.controller,
@@ -485,14 +492,109 @@ export class WorkCenterService {
485
492
 
486
493
  #emit(event) {
487
494
  try { this.onEvent(event); } catch {}
495
+ if (['run.finished', 'coordinator.turn_completed'].includes(event?.type)) {
496
+ for (const action of event.workItem?.actions || []) {
497
+ if (action.status !== 'failed') continue;
498
+ this.#enqueueFailureRecovery({
499
+ workItemId: event.workItem.id,
500
+ actionId: action.id,
501
+ actionGeneration: action.generation,
502
+ });
503
+ }
504
+ }
505
+ }
506
+
507
+ #recoveryKey(entry) {
508
+ return `${entry.workItemId}:${entry.actionId}:${entry.actionGeneration}`;
509
+ }
510
+
511
+ #enqueueFailureRecovery(entry) {
512
+ if (this.shuttingDown || !this.coordinator || !entry?.workItemId || !entry.actionId) return;
513
+ const key = this.#recoveryKey(entry);
514
+ this.recoveryQueue.set(key, entry);
515
+ this.#drainFailureRecoveryQueue();
516
+ }
517
+
518
+ #scanFailureRecoveries() {
519
+ if (this.shuttingDown || !this.coordinator) return;
520
+ const now = Date.now();
521
+ for (const entry of this.store.listFailedActionRecoveries()) {
522
+ const delay = entry.recoveryAttempts > 0
523
+ ? Math.min(1_000 * (2 ** Math.min(entry.recoveryAttempts - 1, 9)), 300_000)
524
+ : 0;
525
+ if (entry.lastRecoveryAt > 0 && now < entry.lastRecoveryAt + delay) continue;
526
+ this.recoveryQueue.set(this.#recoveryKey(entry), entry);
527
+ }
528
+ this.#drainFailureRecoveryQueue();
529
+ }
530
+
531
+ #drainFailureRecoveryQueue() {
532
+ if (this.shuttingDown || !this.coordinator || this.recoveryTasks.size > 0) return;
533
+ let next = null;
534
+ for (const [key, entry] of this.recoveryQueue) {
535
+ const detail = this.store.getWorkItemDetail(entry.workItemId);
536
+ const action = detail?.actions?.find(candidate => candidate.id === entry.actionId);
537
+ if (!detail || ['done', 'cancelled'].includes(detail.status)
538
+ || action?.status !== 'failed'
539
+ || action.generation !== entry.actionGeneration) {
540
+ this.recoveryQueue.delete(key);
541
+ continue;
542
+ }
543
+ if (detail.actions.some(candidate => candidate.status === 'running')) continue;
544
+ next = [key, entry];
545
+ break;
546
+ }
547
+ if (!next) return;
548
+ const [key, entry] = next;
549
+ this.recoveryQueue.delete(key);
550
+ let turn;
551
+ try {
552
+ turn = this.coordinator.recover(entry.workItemId, {
553
+ actionId: entry.actionId,
554
+ actionGeneration: entry.actionGeneration,
555
+ onUpdate: (type, workItem) => {
556
+ this.watcher.abortInvalidWorkItemRuns(entry.workItemId);
557
+ this.#emit({ type, actionId: entry.actionId, workItem });
558
+ },
559
+ });
560
+ } catch (error) {
561
+ this.#emit({
562
+ type: 'coordinator.recovery_schedule_failed',
563
+ actionId: entry.actionId,
564
+ workItem: this.store.getWorkItemDetail(entry.workItemId),
565
+ error: error?.message || String(error),
566
+ });
567
+ return null;
568
+ }
569
+ if (!turn) return null;
570
+ const task = turn.task.finally(() => {
571
+ this.recoveryTasks.delete(key);
572
+ });
573
+ this.recoveryTasks.set(key, task);
574
+ task.catch(() => {});
575
+ return task;
488
576
  }
489
577
 
490
578
  start() {
579
+ this.#scanFailureRecoveries();
580
+ if (!this.recoveryTimer) {
581
+ this.recoveryTimer = setInterval(
582
+ () => this.#scanFailureRecoveries(),
583
+ this.recoveryPollIntervalMs,
584
+ );
585
+ this.recoveryTimer.unref?.();
586
+ }
491
587
  this.watcher.start();
492
588
  }
493
589
 
494
590
  async shutdown() {
591
+ this.shuttingDown = true;
592
+ if (this.recoveryTimer) clearInterval(this.recoveryTimer);
593
+ this.recoveryTimer = null;
495
594
  await this.coordinator?.shutdown?.();
595
+ await Promise.allSettled([...this.recoveryTasks.values()]);
596
+ this.recoveryTasks.clear();
597
+ this.recoveryQueue.clear();
496
598
  await this.watcher.stop();
497
599
  try { await this.watcher.runner?.shutdown?.(); } catch {}
498
600
  try { await this.watcher.runner?.trace?.close?.(); } catch {}
@@ -50,6 +50,28 @@ function coordinatorActionFence(actions) {
50
50
  })).sort((left, right) => left.id.localeCompare(right.id))), 'utf8').digest('hex');
51
51
  }
52
52
 
53
+ function coordinatorRecoveryIdentity(value) {
54
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
55
+ const actionId = typeof value.actionId === 'string' ? value.actionId : '';
56
+ const stageId = typeof value.stageId === 'string' ? value.stageId : '';
57
+ const actionGeneration = Number(value.actionGeneration);
58
+ if (!actionId || !stageId || !Number.isInteger(actionGeneration) || actionGeneration < 1) return null;
59
+ return { actionId, actionGeneration, stageId };
60
+ }
61
+
62
+ function sameCoordinatorRecoveryIdentity(persisted, expected) {
63
+ const persistedPresent = persisted != null;
64
+ const expectedPresent = expected != null;
65
+ if (!persistedPresent && !expectedPresent) return true;
66
+ if (persistedPresent !== expectedPresent) return false;
67
+ const persistedIdentity = coordinatorRecoveryIdentity(persisted);
68
+ const expectedIdentity = coordinatorRecoveryIdentity(expected);
69
+ return !!persistedIdentity && !!expectedIdentity
70
+ && persistedIdentity.actionId === expectedIdentity.actionId
71
+ && persistedIdentity.actionGeneration === expectedIdentity.actionGeneration
72
+ && persistedIdentity.stageId === expectedIdentity.stageId;
73
+ }
74
+
53
75
  function actionSpecHash(action) {
54
76
  const spec = {
55
77
  type: action.type || '',
@@ -1991,6 +2013,26 @@ export class WorkItemStore {
1991
2013
  }));
1992
2014
  }
1993
2015
 
2016
+ listFailedActionRecoveries() {
2017
+ return this.db.prepare(`SELECT a.work_item_id, a.id AS action_id, a.generation,
2018
+ COUNT(recovery_event.id) AS recovery_attempts,
2019
+ MAX(recovery_event.created_at) AS last_recovery_at
2020
+ FROM actions a JOIN work_items w ON w.id = a.work_item_id
2021
+ LEFT JOIN events recovery_event ON recovery_event.work_item_id = a.work_item_id
2022
+ AND recovery_event.action_id = a.id
2023
+ AND recovery_event.action_generation = a.generation
2024
+ AND recovery_event.type = 'coordinator.recovery_started'
2025
+ WHERE a.status = 'failed' AND w.status NOT IN ('done', 'cancelled')
2026
+ GROUP BY a.id
2027
+ ORDER BY a.updated_at, a.sequence, a.id`).all().map(row => ({
2028
+ workItemId: row.work_item_id,
2029
+ actionId: row.action_id,
2030
+ actionGeneration: Math.max(1, Number(row.generation) || 1),
2031
+ recoveryAttempts: Math.max(0, Number(row.recovery_attempts) || 0),
2032
+ lastRecoveryAt: Math.max(0, Number(row.last_recovery_at) || 0),
2033
+ }));
2034
+ }
2035
+
1994
2036
  listWorkItems(filters = {}) {
1995
2037
  const where = [];
1996
2038
  const values = [];
@@ -2133,7 +2175,7 @@ export class WorkItemStore {
2133
2175
  return this.db.prepare(`SELECT * FROM events WHERE action_id = ? ORDER BY id`).all(actionId).map(mapEvent);
2134
2176
  }
2135
2177
 
2136
- beginCoordinatorTurn(id, text, expected = {}, attachments = null, addedAttachments = []) {
2178
+ beginCoordinatorTurn(id, text, expected = {}, options = {}) {
2137
2179
  return withTransaction(this.db, () => {
2138
2180
  const workItem = this.getWorkItem(id);
2139
2181
  if (!workItem) return null;
@@ -2151,32 +2193,56 @@ export class WorkItemStore {
2151
2193
  throw new Error('WorkItem Coordinator is already responding');
2152
2194
  }
2153
2195
  const now = this.now();
2154
- const projectedAttachments = (Array.isArray(addedAttachments) ? addedAttachments : []).map(attachment => ({
2155
- id: attachment.id,
2156
- name: attachment.name,
2157
- mimeType: attachment.mimeType,
2158
- size: Math.max(0, Number(attachment.size) || 0),
2159
- isImage: attachment.isImage === true,
2160
- }));
2161
- if (!String(text || '').trim() && projectedAttachments.length === 0) {
2162
- throw new Error('WorkItem Coordinator message or attachments are required');
2163
- }
2196
+ const projectedAttachments = (Array.isArray(options.addedAttachments) ? options.addedAttachments : [])
2197
+ .map(attachment => ({
2198
+ id: attachment.id,
2199
+ name: attachment.name,
2200
+ mimeType: attachment.mimeType,
2201
+ size: Math.max(0, Number(attachment.size) || 0),
2202
+ isImage: attachment.isImage === true,
2203
+ }));
2164
2204
  const activeActions = this.db.prepare(`SELECT * FROM actions WHERE work_item_id = ?
2165
2205
  AND status NOT IN ('completed', 'superseded', 'cancelled') ORDER BY sequence`).all(id).map(mapAction);
2166
2206
  this.#assertNoIntegrationReservation(activeActions, now);
2207
+ let recovery = options.recovery && typeof options.recovery === 'object'
2208
+ ? { ...options.recovery } : null;
2209
+ if (recovery) {
2210
+ const failedAction = activeActions.find(action => action.id === recovery.actionId);
2211
+ if (['done', 'cancelled'].includes(workItem.status)
2212
+ || failedAction?.status !== 'failed'
2213
+ || failedAction.generation !== recovery.actionGeneration
2214
+ || failedAction.stageId !== recovery.stageId) {
2215
+ throw new Error('WorkItem failure changed before Coordinator recovery started');
2216
+ }
2217
+ const priorAttempts = Number(this.db.prepare(`SELECT COUNT(*) AS count FROM events
2218
+ WHERE work_item_id = ? AND type = 'coordinator.recovery_started'
2219
+ AND action_id = ? AND action_generation = ?`).get(
2220
+ id,
2221
+ failedAction.id,
2222
+ failedAction.generation,
2223
+ )?.count) || 0;
2224
+ recovery = { ...recovery, attempt: priorAttempts + 1 };
2225
+ }
2226
+ if (!recovery && !String(text || '').trim() && projectedAttachments.length === 0) {
2227
+ throw new Error('WorkItem Coordinator message or attachments are required');
2228
+ }
2167
2229
  const turnId = randomUUID();
2168
- const userMessage = {
2230
+ const userMessage = recovery ? null : {
2169
2231
  id: randomUUID(), turnId, role: 'user', text, attachments: projectedAttachments,
2170
2232
  status: 'completed', createdAt: now,
2171
2233
  };
2172
2234
  const assistantMessage = {
2173
2235
  id: randomUUID(), turnId, role: 'assistant', text: '', status: 'thinking',
2174
2236
  createdAt: now, updatedAt: now, decision: null,
2237
+ ...(recovery ? { recovery: { ...recovery } } : {}),
2175
2238
  };
2176
- const messages = [...(workItem.messages || []), userMessage, assistantMessage].slice(-100);
2239
+ const messages = [...(workItem.messages || []), ...(userMessage ? [userMessage] : []), assistantMessage]
2240
+ .slice(-100);
2177
2241
  const coordinatorRevision = workItem.coordinatorRevision + 1;
2178
2242
  const revision = workItem.revision + (projectedAttachments.length > 0 ? 1 : 0);
2179
- const nextAttachments = Array.isArray(attachments) ? attachments : workItem.attachments;
2243
+ const nextAttachments = Array.isArray(options.attachments)
2244
+ ? options.attachments
2245
+ : workItem.attachments;
2180
2246
  const changed = this.db.prepare(`UPDATE work_items SET messages = ?, attachments = ?, revision = ?, coordinator_revision = ?, updated_at = ?
2181
2247
  WHERE id = ? AND coordinator_revision = ? AND revision = ? AND plan_revision = ?
2182
2248
  AND ledger_revision = ?`).run(
@@ -2184,9 +2250,15 @@ export class WorkItemStore {
2184
2250
  id, workItem.coordinatorRevision, workItem.revision, workItem.planRevision, workItem.ledgerRevision,
2185
2251
  );
2186
2252
  if (Number(changed.changes) !== 1) throw new Error('Coordinator turn lost its revision fence');
2187
- this.appendEvent(id, 'coordinator.turn_started', {
2188
- turnId, status: 'thinking', coordinatorRevision, addedAttachmentCount: projectedAttachments.length,
2189
- });
2253
+ this.appendEvent(id, recovery ? 'coordinator.recovery_started' : 'coordinator.turn_started', {
2254
+ turnId,
2255
+ status: 'thinking',
2256
+ coordinatorRevision,
2257
+ addedAttachmentCount: projectedAttachments.length,
2258
+ }, recovery ? {
2259
+ actionId: recovery.actionId,
2260
+ actionGeneration: recovery.actionGeneration,
2261
+ } : {});
2190
2262
  const detail = this.getWorkItemDetail(id);
2191
2263
  return {
2192
2264
  turnId,
@@ -2199,6 +2271,7 @@ export class WorkItemStore {
2199
2271
  coordinatorRevision,
2200
2272
  status: workItem.status,
2201
2273
  actionFence: coordinatorActionFence(activeActions),
2274
+ recovery: recovery ? { ...recovery } : null,
2202
2275
  },
2203
2276
  };
2204
2277
  });
@@ -2220,6 +2293,11 @@ export class WorkItemStore {
2220
2293
  message?.turnId === turnId && message.role === 'assistant' && message.status === 'thinking'
2221
2294
  ));
2222
2295
  if (assistantIndex < 0) return null;
2296
+ const persistedRecovery = messages[assistantIndex]?.recovery ?? null;
2297
+ if (!sameCoordinatorRecoveryIdentity(persistedRecovery, expected.recovery ?? null)) {
2298
+ throw new Error('Coordinator recovery fence does not match the persisted turn identity');
2299
+ }
2300
+ const recovery = coordinatorRecoveryIdentity(persistedRecovery);
2223
2301
  const decision = result?.decision || {};
2224
2302
  const now = this.now();
2225
2303
  const activeActions = this.db.prepare(`SELECT * FROM actions WHERE work_item_id = ?
@@ -2230,13 +2308,63 @@ export class WorkItemStore {
2230
2308
  this.#assertNoIntegrationReservation(activeActions, now);
2231
2309
 
2232
2310
  const graphMode = isGraphWorkItem(workItem);
2233
- if (decision.kind !== 'answer'
2311
+ if (decision.kind === 'replan'
2234
2312
  && (!graphMode || workItem.workflowSnapshot?.planningMode !== 'ai')) {
2235
- throw new Error('Coordinator Action changes require an AI-planned Action graph');
2313
+ throw new Error('Coordinator replan requires an AI-planned Action graph');
2314
+ }
2315
+ if (recovery && decision.kind === 'guide_actions') {
2316
+ const failedAction = activeActions.find(action => (
2317
+ action.id === recovery.actionId
2318
+ && action.generation === recovery.actionGeneration
2319
+ && action.stageId === recovery.stageId
2320
+ && action.status === 'failed'
2321
+ ));
2322
+ if (!failedAction
2323
+ || !Array.isArray(decision.guidance)
2324
+ || decision.guidance.length !== 1
2325
+ || decision.guidance[0]?.stageId !== failedAction.stageId) {
2326
+ throw new Error('Coordinator recovery guidance must target only the failed Action identity');
2327
+ }
2236
2328
  }
2237
2329
  let nextWorkItem = workItem;
2238
2330
  let affectedActionIds = [];
2239
- if (decision.kind === 'guide_actions') {
2331
+ if (decision.kind === 'request_human') {
2332
+ const action = recovery ? activeActions.find(candidate => (
2333
+ candidate.id === recovery.actionId
2334
+ && candidate.generation === recovery.actionGeneration
2335
+ )) : null;
2336
+ if (!action || action.status !== 'failed') {
2337
+ throw new Error('Coordinator human request lost the failed Action fence');
2338
+ }
2339
+ const question = typeof decision.question === 'string' ? decision.question.trim().slice(0, 8_000) : '';
2340
+ if (!question) throw new Error('Coordinator human request requires a question');
2341
+ const changedAction = this.db.prepare(`UPDATE actions SET status = 'waiting', updated_at = ?
2342
+ WHERE id = ? AND status = 'failed' AND generation = ? AND current_run_id IS NULL`).run(
2343
+ now, action.id, action.generation,
2344
+ );
2345
+ if (Number(changedAction.changes) !== 1) {
2346
+ throw new Error('Coordinator human request lost the failed Action generation fence');
2347
+ }
2348
+ const resultRunId = action.resultRunId
2349
+ || this.db.prepare(`SELECT id FROM runs WHERE action_id = ? AND status = 'failed'
2350
+ ORDER BY ended_at DESC, started_at DESC LIMIT 1`).get(action.id)?.id;
2351
+ if (!resultRunId) throw new Error('Coordinator human request requires a failed Run');
2352
+ const changedRun = this.db.prepare(`UPDATE runs SET waiting_reason = ?
2353
+ WHERE id = ? AND action_id = ? AND status = 'failed'`).run(question, resultRunId, action.id);
2354
+ if (Number(changedRun.changes) !== 1) {
2355
+ throw new Error('Coordinator human request lost the failed Run fence');
2356
+ }
2357
+ affectedActionIds = [action.id];
2358
+ this.appendEvent(workItem.id, 'action.waiting', {
2359
+ reason: question,
2360
+ source: 'coordinator',
2361
+ turnId,
2362
+ }, {
2363
+ actionId: action.id,
2364
+ runId: resultRunId,
2365
+ actionGeneration: action.generation,
2366
+ });
2367
+ } else if (decision.kind === 'guide_actions') {
2240
2368
  const guidanceByStage = new Map(decision.guidance.map(entry => [entry.stageId, entry.instruction]));
2241
2369
  for (const action of activeActions) {
2242
2370
  const instruction = guidanceByStage.get(action.stageId);
@@ -2344,7 +2472,7 @@ export class WorkItemStore {
2344
2472
  );
2345
2473
  }
2346
2474
 
2347
- const graphState = decision.kind === 'answer' ? null : this.#graphWorkItemState(workItem.id);
2475
+ const graphState = ['answer'].includes(decision.kind) ? null : this.#graphWorkItemState(workItem.id);
2348
2476
  messages[assistantIndex] = {
2349
2477
  ...messages[assistantIndex], text: result.reply, status: 'completed', updatedAt: now,
2350
2478
  decision: {
@@ -2724,6 +2852,16 @@ export class WorkItemStore {
2724
2852
  const row = this.db.prepare(`SELECT a.* FROM actions a
2725
2853
  JOIN work_items w ON w.id = a.work_item_id
2726
2854
  WHERE a.status = 'ready' AND a.current_run_id IS NULL
2855
+ AND NOT EXISTS (
2856
+ SELECT 1 FROM actions failed_recovery
2857
+ WHERE failed_recovery.work_item_id = a.work_item_id
2858
+ AND failed_recovery.status = 'failed'
2859
+ )
2860
+ AND NOT (
2861
+ json_extract(w.messages, '$[#-1].role') = 'assistant'
2862
+ AND json_extract(w.messages, '$[#-1].status') = 'thinking'
2863
+ AND json_type(w.messages, '$[#-1].recovery') IS NOT NULL
2864
+ )
2727
2865
  AND (
2728
2866
  (COALESCE(json_extract(w.workflow_snapshot, '$.executionMode'), 'linear') != 'graph'
2729
2867
  AND w.status = 'ready' AND w.current_action_id = a.id AND w.current_run_id IS NULL)