@yeaft/webchat-agent 0.1.497 → 0.1.499

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/crew/routing.js CHANGED
@@ -235,7 +235,7 @@ export function resolveRoleName(to, session, fromRole) {
235
235
  * @param {Array<{mimeType, data}>} [turnImages] - auto-attached images from the turn (max 3)
236
236
  */
237
237
  export async function executeRoute(session, fromRole, route, turnImages = []) {
238
- const { to, summary, taskId, taskTitle } = route;
238
+ let { to, summary, taskId, taskTitle } = route;
239
239
 
240
240
  // Auto-resume: paused/stopped → running (route execution means work should continue)
241
241
  if (session.status === 'paused' || session.status === 'stopped') {
@@ -244,14 +244,51 @@ export async function executeRoute(session, fromRole, route, turnImages = []) {
244
244
  sendStatusUpdate(session);
245
245
  }
246
246
 
247
+ // ─── task-321: taskId fallback chain ─────────────────────────────
248
+ // When a ROUTE omits `task:` (shorthand, bare dispatch, human messages,
249
+ // PM forgetting the field), fall back to:
250
+ // (a) the sender's currentTask.taskId
251
+ // (b) the most recent non-system entry in session.messageHistory
252
+ // This keeps prev-* / designer / architect / shorthand messages from
253
+ // becoming taskId=null orphans that never appear on any feature card.
254
+ if (!taskId) {
255
+ const fromRoleState = session.roleStates?.get(fromRole);
256
+ if (fromRoleState?.currentTask?.taskId) {
257
+ taskId = fromRoleState.currentTask.taskId;
258
+ taskTitle = taskTitle || fromRoleState.currentTask.taskTitle || null;
259
+ } else if (Array.isArray(session.messageHistory) && session.messageHistory.length > 0) {
260
+ for (let i = session.messageHistory.length - 1; i >= 0; i--) {
261
+ const h = session.messageHistory[i];
262
+ if (h && h.from !== 'system' && h.taskId) {
263
+ taskId = h.taskId;
264
+ break;
265
+ }
266
+ }
267
+ }
268
+ // Mirror the fallback back into the route object so downstream
269
+ // consumers (dispatchToRole / sendCrewOutput) see the inferred id.
270
+ if (taskId) {
271
+ route.taskId = taskId;
272
+ if (taskTitle) route.taskTitle = taskTitle;
273
+ }
274
+ }
275
+
247
276
  // Task 文件自动管理(fire-and-forget)
248
277
  if (taskId && summary) {
249
278
  const fromRoleConfig = session.roles.get(fromRole);
250
- if (fromRoleConfig?.isDecisionMaker && taskTitle && to !== 'human') {
251
- ensureTaskFile(session, taskId, taskTitle, to, summary)
279
+ // task-321: Auto-create feature file even when a non-PM role is the
280
+ // first to mention the taskId. Any role carrying a taskId (PM, devs,
281
+ // reviewers, designer, architect) now triggers creation — not just PM
282
+ // with explicit taskTitle. appendTaskRecord itself also creates the
283
+ // file if missing, so this is a best-effort fast path.
284
+ const effectiveTitle = taskTitle
285
+ || session.features?.get(taskId)?.taskTitle
286
+ || null;
287
+ if (effectiveTitle && to !== 'human') {
288
+ ensureTaskFile(session, taskId, effectiveTitle, fromRoleConfig?.isDecisionMaker ? to : fromRole, summary)
252
289
  .catch(e => console.warn(`[Crew] Failed to create task file ${taskId}:`, e.message));
253
290
  }
254
- appendTaskRecord(session, taskId, fromRole, summary)
291
+ appendTaskRecord(session, taskId, fromRole, summary, { taskTitle: effectiveTitle })
255
292
  .catch(e => console.warn(`[Crew] Failed to append task record ${taskId}:`, e.message));
256
293
 
257
294
  // 更新工作看板:推断状态
@@ -375,8 +412,13 @@ export async function dispatchToRole(session, roleName, content, fromSource, tas
375
412
  }
376
413
 
377
414
  // 设置 task
415
+ // task-321: keep currentTask sticky. A new taskId updates it; a dispatch
416
+ // without taskId preserves the previous currentTask so subsequent
417
+ // sendCrewOutput calls (which read roleState.currentTask.taskId) keep
418
+ // attaching to the right feature card — instead of falling back to null
419
+ // the moment the sender omits the `task:` field.
378
420
  if (taskId) {
379
- roleState.currentTask = { taskId, taskTitle };
421
+ roleState.currentTask = { taskId, taskTitle: taskTitle || roleState.currentTask?.taskTitle || null };
380
422
  }
381
423
 
382
424
  // Task 上下文注入
@@ -59,15 +59,41 @@ ${m.workRecord}
59
59
 
60
60
  /**
61
61
  * 追加工作记录到 task 文件
62
+ *
63
+ * task-321: auto-create the feature file if it doesn't exist yet. Previously
64
+ * a missing file silently dropped the record, which meant that whenever a
65
+ * non-PM role was first to mention a taskId, the file was never created and
66
+ * every subsequent record — including from PM — would also be dropped. We
67
+ * now recover a title from opts.taskTitle → session.features cache → taskId
68
+ * itself, and create the file on the fly.
69
+ *
70
+ * @param {object} session
71
+ * @param {string} taskId
72
+ * @param {string} roleName
73
+ * @param {string} summary
74
+ * @param {{ taskTitle?: string|null, assignee?: string|null }} [opts]
62
75
  */
63
- export async function appendTaskRecord(session, taskId, roleName, summary) {
76
+ export async function appendTaskRecord(session, taskId, roleName, summary, opts = {}) {
64
77
  const filePath = join(session.sharedDir, 'context', 'features', `${taskId}.md`);
65
78
 
79
+ let exists = true;
66
80
  try {
67
81
  await fs.access(filePath);
68
82
  } catch {
69
- // 文件不存在,跳过
70
- return;
83
+ exists = false;
84
+ }
85
+
86
+ if (!exists) {
87
+ const recoveredTitle = opts.taskTitle
88
+ || session.features?.get(taskId)?.taskTitle
89
+ || taskId;
90
+ const assignee = opts.assignee || roleName;
91
+ try {
92
+ await ensureTaskFile(session, taskId, recoveredTitle, assignee, summary);
93
+ } catch (e) {
94
+ console.warn(`[Crew] Failed to auto-create task file ${taskId} on append:`, e.message);
95
+ return;
96
+ }
71
97
  }
72
98
 
73
99
  const role = session.roles.get(roleName);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.497",
3
+ "version": "0.1.499",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -16,13 +16,22 @@
16
16
  import { loadSession } from './session.js';
17
17
  import { sendToServer } from '../connection/buffer.js';
18
18
  import ctx from '../context.js';
19
- import { getThreadStore } from './threads/store.js';
19
+ import { getThreadStore, MAIN_THREAD_ID } from './threads/store.js';
20
20
 
21
21
  /** @type {import('./session.js').Session | null} */
22
22
  let session = null;
23
23
 
24
- /** @type {AbortController | null} */
25
- let currentAbort = null;
24
+ /**
25
+ * task-320: per-thread in-flight AbortController registry.
26
+ *
27
+ * A new message only cancels the prior round on the SAME thread; a message
28
+ * routed to a different thread runs concurrently without aliasing. Keyed by
29
+ * the resolved `targetThreadId` from the dispatcher's `routing_decision`
30
+ * event (we don't know the thread until the router has classified).
31
+ *
32
+ * @type {Map<string, AbortController>}
33
+ */
34
+ const abortByThread = new Map();
26
35
 
27
36
  /** Query timeout in ms — abort if LLM doesn't respond within this window */
28
37
  const QUERY_TIMEOUT_MS = 120_000;
@@ -30,10 +39,22 @@ const QUERY_TIMEOUT_MS = 120_000;
30
39
  /** Virtual conversationId for the Unify session */
31
40
  let unifyConversationId = null;
32
41
 
33
- /** Accumulated conversation messages for context continuity across queries.
34
- * Each entry is { role: 'user'|'assistant', content: string|Array }.
35
- * Cleared on session reset or consolidation. */
36
- let conversationMessages = [];
42
+ /**
43
+ * task-320: per-thread accumulated conversation messages for context
44
+ * continuity. Previously a single flat array — which cross-contaminated
45
+ * history across threads. Keyed by threadId. Cleared on session reset or
46
+ * by a `consolidate` event for that thread only.
47
+ *
48
+ * @type {Map<string, Array<{role: 'user'|'assistant', content: string|Array}>>}
49
+ */
50
+ const messagesByThread = new Map();
51
+
52
+ function getThreadMessages(threadId) {
53
+ if (!threadId) return [];
54
+ let arr = messagesByThread.get(threadId);
55
+ if (!arr) { arr = []; messagesByThread.set(threadId, arr); }
56
+ return arr;
57
+ }
37
58
 
38
59
  /** Whether we've already sent a permission warning to the UI */
39
60
  let _permissionDiagnosticSent = false;
@@ -395,8 +416,13 @@ function handleEngineEvent(event, threadId, hctx) {
395
416
  break;
396
417
 
397
418
  case 'consolidate':
398
- // Engine compressed the context — clear our accumulated history.
399
- conversationMessages = [];
419
+ // Engine compressed the context — clear our accumulated history for
420
+ // THIS thread only (task-320: per-thread history map).
421
+ if (threadId) {
422
+ messagesByThread.set(threadId, []);
423
+ } else {
424
+ messagesByThread.clear();
425
+ }
400
426
  sendUnifyEvent({
401
427
  type: 'consolidate',
402
428
  archivedCount: event.archivedCount,
@@ -512,11 +538,16 @@ export async function handleUnifyChat(msg) {
512
538
  // Create a stable conversationId for the Unify session
513
539
  unifyConversationId = `unify-${Date.now()}`;
514
540
 
515
- // Restore conversationMessages from persisted history for LLM context
541
+ // Restore per-thread history from persisted conversation store.
542
+ // task-320: bucket by threadId so each thread keeps its own context.
543
+ messagesByThread.clear();
516
544
  const recent = session.conversationStore.loadRecent(50);
517
- conversationMessages = recent
518
- .filter(m => m.role === 'user' || m.role === 'assistant')
519
- .map(m => ({ role: m.role, content: m.content }));
545
+ for (const m of recent) {
546
+ if (m.role !== 'user' && m.role !== 'assistant') continue;
547
+ const tid = m.threadId || MAIN_THREAD_ID;
548
+ const bucket = getThreadMessages(tid);
549
+ bucket.push({ role: m.role, content: m.content });
550
+ }
520
551
 
521
552
  // Notify UI: session is ready with model info + conversationId
522
553
  sendUnifyEvent({
@@ -533,13 +564,14 @@ export async function handleUnifyChat(msg) {
533
564
  sendThreadListUpdate();
534
565
  }
535
566
 
536
- // ─── Cancel any in-flight query ──
537
- if (currentAbort) {
538
- currentAbort.abort();
539
- currentAbort = null;
540
- }
541
-
542
- currentAbort = new AbortController();
567
+ // ─── Per-call AbortController (task-320) ──
568
+ // Each call owns its own controller. Only once the router resolves the
569
+ // target thread do we register it into `abortByThread` and abort any
570
+ // prior controller on THAT same thread. Messages routed to different
571
+ // threads never alias each other's signals.
572
+ const abortCtrl = new AbortController();
573
+ /** @type {string | null} — set on routing_decision */
574
+ let resolvedThreadId = null;
543
575
 
544
576
  // ─── Timeout guard: abort query if LLM hangs beyond threshold ──
545
577
  // Resets on every event — fires only after prolonged silence.
@@ -547,9 +579,9 @@ export async function handleUnifyChat(msg) {
547
579
  const resetQueryTimer = () => {
548
580
  if (queryTimer) clearTimeout(queryTimer);
549
581
  queryTimer = setTimeout(() => {
550
- if (currentAbort) {
582
+ if (!abortCtrl.signal.aborted) {
551
583
  console.error(`[Unify] query timeout after ${QUERY_TIMEOUT_MS / 1000}s of silence — aborting`);
552
- currentAbort.abort();
584
+ abortCtrl.abort();
553
585
  }
554
586
  }, QUERY_TIMEOUT_MS);
555
587
  };
@@ -590,17 +622,31 @@ export async function handleUnifyChat(msg) {
590
622
  onError: (err) => { throw err; },
591
623
  };
592
624
 
593
- for await (const pev of session.dispatcher.drain({ signal: currentAbort.signal })) {
625
+ for await (const pev of session.dispatcher.drain({ signal: abortCtrl.signal })) {
594
626
  resetQueryTimer();
627
+ // task-320: on routing_decision, bind this abort controller to the
628
+ // resolved target thread and abort any prior in-flight controller
629
+ // owned by that thread. Different threads don't alias.
630
+ if (pev && pev.type === 'routing_decision' && pev.targetThreadId && !resolvedThreadId) {
631
+ resolvedThreadId = pev.targetThreadId;
632
+ const prior = abortByThread.get(resolvedThreadId);
633
+ if (prior && prior !== abortCtrl) {
634
+ prior.abort();
635
+ }
636
+ abortByThread.set(resolvedThreadId, abortCtrl);
637
+ }
595
638
  forwardPipelineEvent(pev, pipelineCtx);
596
639
  }
597
640
 
598
641
  // ─── Query complete — accumulate messages for context continuity ──
599
- conversationMessages.push({ role: 'user', content: cleanedPrompt });
642
+ // task-320: per-thread history (no cross-thread contamination).
643
+ const historyThread = resolvedThreadId || MAIN_THREAD_ID;
644
+ const threadMessages = getThreadMessages(historyThread);
645
+ threadMessages.push({ role: 'user', content: cleanedPrompt });
600
646
 
601
647
  const fullText = assistantTextParts.join('');
602
648
  if (fullText) {
603
- conversationMessages.push({ role: 'assistant', content: fullText });
649
+ threadMessages.push({ role: 'assistant', content: fullText });
604
650
  }
605
651
 
606
652
  // ─── Signal turn end to UI ──
@@ -621,17 +667,14 @@ export async function handleUnifyChat(msg) {
621
667
  }
622
668
 
623
669
  } catch (err) {
624
- // Don't report abort errors but still send result to unblock frontend
625
- if (err.name === 'AbortError') {
626
- sendUnifyOutput({
627
- type: 'assistant',
628
- message: {
629
- content: [{
630
- type: 'text',
631
- text: '⚠️ Query timed out no response from LLM. Please try again.',
632
- }],
633
- },
634
- });
670
+ // task-320: classify both DOM AbortError and LLMAbortError as
671
+ // "aborted" LLMAbortError is thrown by the LLM adapters when the
672
+ // signal trips and must NOT render as a session error bubble.
673
+ const isAbort = err && (err.name === 'AbortError' || err.name === 'LLMAbortError');
674
+ if (isAbort) {
675
+ // Silent abort — the new in-flight round (on the same thread) will
676
+ // produce its own output. Still send `result` so the frontend's
677
+ // processing spinner for this exact send clears.
635
678
  sendUnifyOutput({
636
679
  type: 'result',
637
680
  result_text: '',
@@ -672,7 +715,12 @@ export async function handleUnifyChat(msg) {
672
715
  result_text: '',
673
716
  });
674
717
  } finally {
675
- currentAbort = null;
718
+ // task-320: only clear the per-thread slot if THIS controller is still
719
+ // the registered one. If a newer message already overwrote it, leaving
720
+ // the newer controller in the map is the correct state.
721
+ if (resolvedThreadId && abortByThread.get(resolvedThreadId) === abortCtrl) {
722
+ abortByThread.delete(resolvedThreadId);
723
+ }
676
724
  }
677
725
  }
678
726
 
@@ -863,11 +911,16 @@ export async function handleUnifyLoadHistory(msg) {
863
911
 
864
912
  unifyConversationId = `unify-${Date.now()}`;
865
913
 
866
- // Restore conversationMessages from persisted history for LLM context
914
+ // Restore per-thread history from persisted conversation store.
915
+ // task-320: bucket by threadId so each thread keeps its own context.
916
+ messagesByThread.clear();
867
917
  const recent = session.conversationStore.loadRecent(50);
868
- conversationMessages = recent
869
- .filter(m => m.role === 'user' || m.role === 'assistant')
870
- .map(m => ({ role: m.role, content: m.content }));
918
+ for (const m of recent) {
919
+ if (m.role !== 'user' && m.role !== 'assistant') continue;
920
+ const tid = m.threadId || MAIN_THREAD_ID;
921
+ const bucket = getThreadMessages(tid);
922
+ bucket.push({ role: m.role, content: m.content });
923
+ }
871
924
 
872
925
  sendUnifyEvent({
873
926
  type: 'session_ready',
@@ -915,16 +968,19 @@ export async function handleUnifyLoadHistory(msg) {
915
968
  * session_ready so the frontend picks up updated models/config.
916
969
  */
917
970
  export async function resetUnifySession() {
918
- if (currentAbort) {
919
- currentAbort.abort();
920
- currentAbort = null;
971
+ // task-320: abort ALL in-flight controllers (every thread) before
972
+ // tearing down the session. Leaves no dangling round still writing
973
+ // to stdout after shutdown.
974
+ for (const ctrl of abortByThread.values()) {
975
+ try { ctrl.abort(); } catch { /* ignore */ }
921
976
  }
977
+ abortByThread.clear();
922
978
  if (session) {
923
979
  await session.shutdown();
924
980
  session = null;
925
981
  }
926
982
  unifyConversationId = null;
927
- conversationMessages = [];
983
+ messagesByThread.clear();
928
984
 
929
985
  // Re-initialize session immediately so frontend gets updated config
930
986
  try {
@@ -944,11 +1000,13 @@ export async function resetUnifySession() {
944
1000
 
945
1001
  unifyConversationId = `unify-${Date.now()}`;
946
1002
 
947
- // Restore conversation history for LLM context
1003
+ // Restore per-thread history for LLM context (task-320).
948
1004
  const recent = session.conversationStore.loadRecent(50);
949
- conversationMessages = recent
950
- .filter(m => m.role === 'user' || m.role === 'assistant')
951
- .map(m => ({ role: m.role, content: m.content }));
1005
+ for (const m of recent) {
1006
+ if (m.role !== 'user' && m.role !== 'assistant') continue;
1007
+ const tid = m.threadId || MAIN_THREAD_ID;
1008
+ getThreadMessages(tid).push({ role: m.role, content: m.content });
1009
+ }
952
1010
 
953
1011
  sendUnifyEvent({
954
1012
  type: 'session_ready',