@synergenius/flow-weaver-pack-weaver 0.9.140 → 0.9.142

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.
Files changed (68) hide show
  1. package/dist/bot/assistant-tools.d.ts.map +1 -1
  2. package/dist/bot/assistant-tools.js +5 -11
  3. package/dist/bot/assistant-tools.js.map +1 -1
  4. package/dist/bot/capability-registry.d.ts.map +1 -1
  5. package/dist/bot/capability-registry.js +185 -15
  6. package/dist/bot/capability-registry.js.map +1 -1
  7. package/dist/bot/dashboard.js +3 -3
  8. package/dist/bot/dashboard.js.map +1 -1
  9. package/dist/bot/hierarchy-event-log.d.ts +37 -0
  10. package/dist/bot/hierarchy-event-log.d.ts.map +1 -0
  11. package/dist/bot/hierarchy-event-log.js +58 -0
  12. package/dist/bot/hierarchy-event-log.js.map +1 -0
  13. package/dist/bot/operations.d.ts +2 -0
  14. package/dist/bot/operations.d.ts.map +1 -1
  15. package/dist/bot/operations.js +5 -0
  16. package/dist/bot/operations.js.map +1 -1
  17. package/dist/bot/profile-store.d.ts.map +1 -1
  18. package/dist/bot/profile-store.js +39 -0
  19. package/dist/bot/profile-store.js.map +1 -1
  20. package/dist/bot/runner.d.ts.map +1 -1
  21. package/dist/bot/runner.js +7 -17
  22. package/dist/bot/runner.js.map +1 -1
  23. package/dist/bot/step-executor.d.ts.map +1 -1
  24. package/dist/bot/step-executor.js +33 -1
  25. package/dist/bot/step-executor.js.map +1 -1
  26. package/dist/bot/swarm-controller.d.ts +1 -0
  27. package/dist/bot/swarm-controller.d.ts.map +1 -1
  28. package/dist/bot/swarm-controller.js +59 -5
  29. package/dist/bot/swarm-controller.js.map +1 -1
  30. package/dist/bot/task-store.d.ts +1 -1
  31. package/dist/bot/task-store.d.ts.map +1 -1
  32. package/dist/bot/task-store.js +21 -36
  33. package/dist/bot/task-store.js.map +1 -1
  34. package/dist/bot/task-types.d.ts +5 -1
  35. package/dist/bot/task-types.d.ts.map +1 -1
  36. package/dist/node-types/bot-report.js +2 -2
  37. package/dist/node-types/bot-report.js.map +1 -1
  38. package/dist/node-types/build-context.d.ts.map +1 -1
  39. package/dist/node-types/build-context.js +32 -0
  40. package/dist/node-types/build-context.js.map +1 -1
  41. package/dist/node-types/report.d.ts +1 -1
  42. package/dist/node-types/report.d.ts.map +1 -1
  43. package/dist/node-types/report.js +58 -8
  44. package/dist/node-types/report.js.map +1 -1
  45. package/dist/ui/capability-editor.js +184 -15
  46. package/dist/ui/profile-editor.js +184 -15
  47. package/dist/ui/swarm-dashboard.js +244 -44
  48. package/dist/ui/task-detail-view.js +60 -29
  49. package/dist/ui/use-stream-timeline.d.ts.map +1 -1
  50. package/dist/ui/use-stream-timeline.js +69 -29
  51. package/dist/ui/use-stream-timeline.js.map +1 -1
  52. package/flowweaver.manifest.json +1 -1
  53. package/package.json +1 -1
  54. package/src/bot/assistant-tools.ts +5 -11
  55. package/src/bot/capability-registry.ts +196 -18
  56. package/src/bot/dashboard.ts +3 -3
  57. package/src/bot/hierarchy-event-log.ts +64 -0
  58. package/src/bot/operations.ts +7 -0
  59. package/src/bot/profile-store.ts +39 -0
  60. package/src/bot/runner.ts +8 -19
  61. package/src/bot/step-executor.ts +29 -1
  62. package/src/bot/swarm-controller.ts +62 -5
  63. package/src/bot/task-store.ts +22 -38
  64. package/src/bot/task-types.ts +7 -1
  65. package/src/node-types/bot-report.ts +2 -2
  66. package/src/node-types/build-context.ts +32 -0
  67. package/src/node-types/report.ts +56 -8
  68. package/src/ui/use-stream-timeline.ts +73 -33
@@ -14,6 +14,7 @@ import { TaskStore } from './task-store.js';
14
14
  import { BotRegistry } from './bot-registry.js';
15
15
  import type { BotRegistration } from './bot-registry.js';
16
16
  import { SwarmEventLog } from './swarm-event-log.js';
17
+ import { HierarchyEventLog } from './hierarchy-event-log.js';
17
18
  import { RunStore } from './run-store.js';
18
19
  import { EventLog } from './event-log.js';
19
20
  import { runRegistry } from './run-registry.js';
@@ -93,6 +94,7 @@ export class SwarmController {
93
94
  private readonly statePath: string;
94
95
  private readonly taskStore: TaskStore;
95
96
  private readonly eventLog: SwarmEventLog;
97
+ private readonly hierarchyEventLog: HierarchyEventLog;
96
98
  private readonly orchestrator: Orchestrator;
97
99
  private readonly instanceManager: InstanceManager;
98
100
  private readonly profileStore: ProfileStore;
@@ -137,6 +139,7 @@ export class SwarmController {
137
139
  this.statePath = path.join(this.weaverDir, SWARM_STATE_FILE);
138
140
  this.taskStore = new TaskStore(projectDir);
139
141
  this.eventLog = new SwarmEventLog(projectDir);
142
+ this.hierarchyEventLog = new HierarchyEventLog(projectDir);
140
143
  this.orchestrator = new Orchestrator({ aiRouter: new AIRouterImpl(projectDir) });
141
144
  this.instanceManager = new InstanceManager();
142
145
  this.profileStore = new ProfileStore(projectDir);
@@ -313,7 +316,7 @@ export class SwarmController {
313
316
  });
314
317
  } else {
315
318
  await this.taskStore.update(task.id, {
316
- status: 'failed',
319
+ status: 'open',
317
320
  currentBotId: undefined,
318
321
  });
319
322
  }
@@ -426,14 +429,22 @@ export class SwarmController {
426
429
  await this._checkSteering(inst.instanceId);
427
430
  }
428
431
 
432
+ // Route unassigned tasks to orchestrator profile for decomposition
433
+ const unassignedTasks = await this.taskStore.list({ status: ['open', 'pending'] });
434
+ for (const t of unassignedTasks) {
435
+ if (!t.assignedProfile && !t.isParent) {
436
+ await this.taskStore.update(t.id, { assignedProfile: 'orchestrator' });
437
+ }
438
+ }
439
+
429
440
  // Collect pending tasks (and all tasks for dependency checks)
430
- const pendingTasks = await this.taskStore.list({ status: ['pending', 'failed'] });
441
+ const pendingTasks = await this.taskStore.list({ status: ['open', 'pending'] });
431
442
  const allTasks = await this.taskStore.list();
432
443
  const routableTasks = pendingTasks.filter((t) => {
433
444
  // Skip parent tasks
434
445
  if (t.isParent) return false;
435
446
  // Skip tasks that have exhausted retries
436
- if (t.status === 'failed' && t.attempt >= t.maxAttempts) return false;
447
+ if (t.attempt >= t.maxAttempts) return false;
437
448
  // Skip tasks over per-task budget
438
449
  if (t.budgetTokens !== undefined && t.tokensUsed >= t.budgetTokens) return false;
439
450
  if (t.budgetCost !== undefined && t.costUsed >= t.budgetCost) return false;
@@ -503,12 +514,40 @@ export class SwarmController {
503
514
  }
504
515
  }
505
516
 
517
+ // Build file conflict map: which files are being modified by currently executing tasks
518
+ const activeFiles = new Map<string, string>(); // filepath → taskId
519
+ for (const [instId] of this.executionPromises) {
520
+ const inst = this.instanceManager.listAll().find(i => i.instanceId === instId);
521
+ if (inst?.currentTaskId) {
522
+ const runningTask = await this.taskStore.get(inst.currentTaskId);
523
+ if (runningTask?.context.files) {
524
+ for (const f of runningTask.context.files) {
525
+ activeFiles.set(f, runningTask.id);
526
+ }
527
+ }
528
+ }
529
+ }
530
+
506
531
  // Apply assignments
507
532
  for (const assignment of output.assignments) {
508
533
  const profile = profiles.find((p) => p.id === assignment.profileId);
509
534
  const bot = this.profileBotMap.get(assignment.profileId);
510
535
  if (!profile || !bot) continue;
511
536
 
537
+ // File conflict check: skip if task targets files currently being modified
538
+ const candidateTask = routableTasks.find(t => t.id === assignment.taskId);
539
+ if (candidateTask?.context.files?.length) {
540
+ const hasFileConflict = candidateTask.context.files.some(f => activeFiles.has(f));
541
+ if (hasFileConflict) {
542
+ this.eventLog.emit({
543
+ type: 'task-skipped',
544
+ timestamp: Date.now(),
545
+ data: { taskId: assignment.taskId, reason: 'file-conflict' },
546
+ });
547
+ continue; // Skip this assignment, retry next cycle
548
+ }
549
+ }
550
+
512
551
  try {
513
552
  // Assign task in TaskStore
514
553
  await this.taskStore.assignToInstance(assignment.taskId, assignment.instanceId, assignment.profileId);
@@ -668,7 +707,7 @@ export class SwarmController {
668
707
  };
669
708
 
670
709
  // Release task
671
- const releaseStatus = result.success ? 'done' : 'failed';
710
+ const releaseStatus = result.success ? 'done' : 'open';
672
711
  await this.taskStore.release(taskId, releaseStatus, runSummary);
673
712
 
674
713
  // Record token usage
@@ -686,13 +725,31 @@ export class SwarmController {
686
725
  this.instanceManager.markIdle(instanceId, result.success);
687
726
  } catch { /* instance may have been stopped */ }
688
727
 
689
- // Emit task event
728
+ // Emit swarm-level task event
690
729
  this.eventLog.emit({
691
730
  type: result.success ? 'task-done' : 'task-failed',
692
731
  timestamp: Date.now(),
693
732
  data: { botId: instanceId, taskId, outcome: runSummary.outcome },
694
733
  });
695
734
 
735
+ // Emit hierarchy-scoped event so sibling tasks can see what happened
736
+ try {
737
+ const task = await this.taskStore.get(taskId);
738
+ if (task?.parentId) {
739
+ this.hierarchyEventLog.emit({
740
+ parentId: task.parentId,
741
+ type: result.success ? 'task-completed' : 'task-run-failed',
742
+ taskId,
743
+ data: {
744
+ summary: runSummary.summary,
745
+ filesModified: runSummary.filesModified,
746
+ botId: instanceId,
747
+ attempt: task.attempt,
748
+ },
749
+ });
750
+ }
751
+ } catch { /* non-fatal — hierarchy events are best-effort */ }
752
+
696
753
  this._syncInstancesState();
697
754
  this._persist();
698
755
  }
@@ -38,7 +38,7 @@ export class TaskStore {
38
38
  id: parentId,
39
39
  title: input.title,
40
40
  description: input.description,
41
- status: 'pending',
41
+ status: 'open',
42
42
  priority: input.priority ?? 0,
43
43
  isParent: true,
44
44
  parentId: input.parentId,
@@ -77,7 +77,7 @@ export class TaskStore {
77
77
  id: subId,
78
78
  title: sub.title,
79
79
  description: sub.description ?? '',
80
- status: 'pending',
80
+ status: 'open',
81
81
  priority: sub.priority ?? input.priority ?? 0,
82
82
  isParent: false,
83
83
  parentId: parentId,
@@ -115,7 +115,7 @@ export class TaskStore {
115
115
  id: crypto.randomUUID().slice(0, 8),
116
116
  title: input.title,
117
117
  description: input.description,
118
- status: 'pending',
118
+ status: 'open',
119
119
  priority: input.priority ?? 0,
120
120
  isParent: false,
121
121
  parentId: input.parentId,
@@ -233,8 +233,8 @@ export class TaskStore {
233
233
 
234
234
  const task = tasks[idx];
235
235
  const assignable =
236
- task.status === 'pending' ||
237
- (task.status === 'failed' && task.attempt < task.maxAttempts);
236
+ (task.status === 'open' || task.status === 'pending') &&
237
+ task.attempt < task.maxAttempts;
238
238
  if (!assignable) {
239
239
  throw new Error(`Task ${taskId} is not assignable (status: ${task.status}, attempt: ${task.attempt}/${task.maxAttempts})`);
240
240
  }
@@ -254,7 +254,7 @@ export class TaskStore {
254
254
  // Release
255
255
  // ---------------------------------------------------------------------------
256
256
 
257
- async release(taskId: string, status: 'done' | 'failed', runSummary: RunSummary): Promise<Task> {
257
+ async release(taskId: string, status: 'done' | 'failed' | 'open', runSummary: RunSummary): Promise<Task> {
258
258
  return this.mutex.runExclusive(async () => {
259
259
  const tasks = this._readAll();
260
260
  const idx = tasks.findIndex(t => t.id === taskId);
@@ -279,7 +279,8 @@ export class TaskStore {
279
279
 
280
280
  // Update execution tracking
281
281
  task.attempt += 1;
282
- task.status = status;
282
+ // Tasks don't fail — runs fail. Map 'failed' to 'open' for backward compat.
283
+ task.status = status === 'failed' ? 'open' : status;
283
284
  task.currentBotId = undefined;
284
285
  task.currentRunId = undefined;
285
286
  task.updatedAt = new Date().toISOString();
@@ -287,9 +288,7 @@ export class TaskStore {
287
288
  if (status === 'done') {
288
289
  task.completedAt = new Date().toISOString();
289
290
  }
290
- if (status === 'failed') {
291
- task.completedAt = new Date().toISOString();
292
- }
291
+ // 'open' and 'failed' → task stays open, no completedAt
293
292
 
294
293
  tasks[idx] = task;
295
294
 
@@ -308,28 +307,19 @@ export class TaskStore {
308
307
 
309
308
  private _handleDependencyEffects(tasks: Task[], changedTask: Task): void {
310
309
  if (changedTask.status === 'done') {
311
- // Unblock dependents: set blocked tasks to pending if all deps are now done
310
+ // Unblock dependents: set blocked tasks to open if all deps are now done
312
311
  const doneIds = new Set(tasks.filter(t => t.status === 'done').map(t => t.id));
313
312
  for (const t of tasks) {
314
313
  if (t.status === 'blocked' && t.dependsOn.includes(changedTask.id)) {
315
314
  if (t.dependsOn.every(depId => doneIds.has(depId))) {
316
- t.status = 'pending';
317
- t.updatedAt = new Date().toISOString();
318
- }
319
- }
320
- }
321
- } else if (changedTask.status === 'failed') {
322
- // Check if failure is terminal (no retries left)
323
- if (changedTask.attempt >= changedTask.maxAttempts) {
324
- // Block dependents
325
- for (const t of tasks) {
326
- if (t.status === 'pending' && t.dependsOn.includes(changedTask.id)) {
327
- t.status = 'blocked';
315
+ t.status = 'open';
328
316
  t.updatedAt = new Date().toISOString();
329
317
  }
330
318
  }
331
319
  }
332
320
  }
321
+ // Tasks don't fail — runs fail. Dependents stay blocked until dep
322
+ // succeeds (status=done). No terminal 'failed' state to handle.
333
323
  }
334
324
 
335
325
  private _handleParentEffects(tasks: Task[], changedTask: Task): void {
@@ -351,14 +341,8 @@ export class TaskStore {
351
341
  return;
352
342
  }
353
343
 
354
- // Any terminal failure => parent failed
355
- const hasTerminalFailure = subtasks.some(
356
- s => s.status === 'failed' && s.attempt >= s.maxAttempts,
357
- );
358
- if (hasTerminalFailure) {
359
- parent.status = 'failed';
360
- parent.updatedAt = new Date().toISOString();
361
- }
344
+ // Tasks don't fail. Parent stays open until all subtasks are done
345
+ // or user cancels. No terminal 'failed' state for parent.
362
346
  }
363
347
 
364
348
  // ---------------------------------------------------------------------------
@@ -369,7 +353,7 @@ export class TaskStore {
369
353
  return this.mutex.runExclusive(async () => {
370
354
  const tasks = this._readAll();
371
355
  const before = tasks.length;
372
- const kept = tasks.filter(t => t.status !== 'done' && t.status !== 'failed' && t.status !== 'cancelled');
356
+ const kept = tasks.filter(t => t.status !== 'done' && t.status !== 'cancelled');
373
357
  this._writeAll(kept);
374
358
  return before - kept.length;
375
359
  });
@@ -389,17 +373,17 @@ export class TaskStore {
389
373
  // ---------------------------------------------------------------------------
390
374
 
391
375
  private _findDuplicate(tasks: Task[], title: string, description: string): Task | null {
392
- // Check pending duplicates
393
- const pendingDup = tasks.find(
394
- t => t.status === 'pending' && t.title === title && t.description === description,
376
+ // Check open/pending duplicates
377
+ const openDup = tasks.find(
378
+ t => (t.status === 'open' || t.status === 'pending') && t.title === title && t.description === description,
395
379
  );
396
- if (pendingDup) return pendingDup;
380
+ if (openDup) return openDup;
397
381
 
398
- // Check recently completed/failed (within dedup window)
382
+ // Check recently completed (within dedup window)
399
383
  const now = Date.now();
400
384
  const recentDup = tasks.find(
401
385
  t =>
402
- (t.status === 'done' || t.status === 'failed') &&
386
+ t.status === 'done' &&
403
387
  t.title === title &&
404
388
  t.description === description &&
405
389
  t.completedAt !== undefined &&
@@ -22,7 +22,13 @@ export interface TaskContext {
22
22
  lastError?: string;
23
23
  }
24
24
 
25
- export type TaskStatus = 'pending' | 'in-progress' | 'blocked' | 'done' | 'failed' | 'cancelled';
25
+ /**
26
+ * Task status. Tasks NEVER go to 'failed' — runs fail, tasks stay open.
27
+ * 'open' replaces 'pending'. A task is open until done or cancelled.
28
+ */
29
+ export type TaskStatus = 'open' | 'pending' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
30
+ // NOTE: 'pending' kept for backward compatibility with existing data.
31
+ // New code should use 'open'. Both are treated as assignable.
26
32
 
27
33
  export interface Task {
28
34
  id: string;
@@ -120,8 +120,8 @@ export async function weaverBotReport(
120
120
  if (task.queueId && context.env?.projectDir) {
121
121
  try {
122
122
  const store = new TaskStore(context.env.projectDir);
123
- await store.update(task.queueId, { status: success ? 'done' : 'failed' });
124
- console.log(`\x1b[36m→ Queue task ${task.queueId}: ${success ? 'done' : 'failed'}\x1b[0m`);
123
+ await store.update(task.queueId, { status: success ? 'done' : 'open' });
124
+ console.log(`\x1b[36m→ Queue task ${task.queueId}: ${success ? 'done' : 'open'}\x1b[0m`);
125
125
  } catch (err) { if (process.env.WEAVER_VERBOSE) console.error('[bot-report] queue update failed:', err); }
126
126
  }
127
127
 
@@ -2,6 +2,7 @@ import { execFileSync } from 'node:child_process';
2
2
  import * as fs from 'node:fs';
3
3
  import * as path from 'node:path';
4
4
  import type { WeaverContext } from '../bot/types.js';
5
+ import { HierarchyEventLog } from '../bot/hierarchy-event-log.js';
5
6
 
6
7
  /**
7
8
  * Builds the knowledge bundle the AI needs for planning.
@@ -31,6 +32,37 @@ export function weaverBuildContext(ctx: string): { ctx: string } {
31
32
  sections.push(...buildFullContext(projectDir, task.mode));
32
33
  }
33
34
 
35
+ // Auto-recall project memory (conventions persisted by previous bots)
36
+ try {
37
+ const memPath = path.join(projectDir, '.weaver', 'project-memory.json');
38
+ if (fs.existsSync(memPath)) {
39
+ const memory = JSON.parse(fs.readFileSync(memPath, 'utf-8')) as Record<string, string>;
40
+ const entries = Object.entries(memory);
41
+ if (entries.length > 0) {
42
+ const memLines = entries.map(([key, value]) => `- **${key}**: ${value}`);
43
+ sections.push(`## Project Conventions (from memory)\n\nFollow these established patterns:\n${memLines.join('\n')}`);
44
+ }
45
+ }
46
+ } catch { /* non-fatal — memory is best-effort */ }
47
+
48
+ // Include sibling context from hierarchy events (previous task completions in same hierarchy)
49
+ try {
50
+ const parsedTask = JSON.parse(context.taskJson!) as { parentId?: string };
51
+ const parentId = parsedTask.parentId;
52
+ if (parentId) {
53
+ const hierarchyLog = new HierarchyEventLog(projectDir);
54
+ const siblingEvents = hierarchyLog.tailByParent(parentId);
55
+ if (siblingEvents.length > 0) {
56
+ const siblingLines = siblingEvents.map(e => {
57
+ const d = e.data ?? {};
58
+ const files = (d.filesModified as string[])?.join(', ') || 'none';
59
+ return `- ${e.type}: ${d.summary ?? e.taskId} (files: ${files})`;
60
+ });
61
+ sections.push(`## Previous Task Completions\n\nYour sibling tasks in this hierarchy have completed:\n${siblingLines.join('\n')}`);
62
+ }
63
+ }
64
+ } catch { /* non-fatal — sibling context is best-effort */ }
65
+
34
66
  const bundle = sections.join('\n\n---\n\n');
35
67
  // Output handled by session renderer; keep a dim line for debugging
36
68
  if (process.env.WEAVER_VERBOSE) process.stderr.write(`\x1b[2m Context: ${bundle.length} chars\x1b[0m\n`);
@@ -10,18 +10,66 @@ import type { WeaverContext } from '../bot/types.js';
10
10
  * @icon summarize
11
11
  * @color cyan
12
12
  * @input ctx [order:0] - Weaver context (JSON)
13
- * @output summary [order:0] - Summary string
13
+ * @output summary [order:0, format:md] - Summary
14
14
  * @output onFailure [hidden]
15
15
  */
16
16
  export function weaverReport(ctx: string): { summary: string } {
17
17
  const context = JSON.parse(ctx) as WeaverContext;
18
18
  const result = JSON.parse(context.resultJson!);
19
19
  const relPath = path.relative(context.env.projectDir, context.targetPath!);
20
- const lines = [
21
- `Weaver: ${result.outcome} (${relPath})`,
22
- result.summary,
23
- ];
24
- if (result.executionTime) lines.push(`Time: ${result.executionTime}s`);
25
- console.log(`\x1b[32m✓ ${lines[0]}\x1b[0m`);
26
- return { summary: lines.join('\n') };
20
+
21
+ console.log(`\x1b[32m✓ Weaver: ${result.outcome} (${relPath})\x1b[0m`);
22
+
23
+ const md: string[] = [];
24
+ md.push(`## ${result.outcome === 'success' ? 'Task Completed' : 'Task Failed'}`);
25
+ md.push('');
26
+ if (result.summary) md.push(result.summary);
27
+ md.push('');
28
+
29
+ // Steps
30
+ try {
31
+ const steps = JSON.parse(context.stepLogJson ?? '[]') as Array<{ step: string; status: string; detail?: string }>;
32
+ if (steps.length > 0) {
33
+ md.push('### Steps');
34
+ md.push('');
35
+ for (const s of steps) {
36
+ const icon = s.status === 'ok' ? '**ok**' : s.status === 'error' ? '**error**' : '**blocked**';
37
+ md.push(`- ${s.step} (${icon})${s.detail ? `: ${s.detail}` : ''}`);
38
+ }
39
+ md.push('');
40
+ }
41
+ } catch { /* skip */ }
42
+
43
+ // Files
44
+ try {
45
+ const files = JSON.parse(context.filesModified ?? '[]') as string[];
46
+ if (files.length > 0) {
47
+ md.push('### Files Modified');
48
+ md.push('');
49
+ for (const f of files) md.push(`- \`${f}\``);
50
+ md.push('');
51
+ }
52
+ } catch { /* skip */ }
53
+
54
+ // Review
55
+ try {
56
+ const review = JSON.parse(context.reviewJson ?? '{}') as Record<string, string>;
57
+ if (review.intent || review.execution || review.result || review.completeness) {
58
+ md.push('### Review');
59
+ md.push('');
60
+ for (const key of ['intent', 'execution', 'result', 'completeness']) {
61
+ if (review[key]) md.push(`- **${key}:** ${review[key]}`);
62
+ }
63
+ if (review.reason) md.push(`\n${review.reason}`);
64
+ md.push('');
65
+ }
66
+ } catch { /* skip */ }
67
+
68
+ // Meta
69
+ const meta: string[] = [];
70
+ if (result.executionTime) meta.push(`**Duration:** ${result.executionTime}s`);
71
+ if (context.env.providerInfo?.model) meta.push(`**Model:** ${context.env.providerInfo.model}`);
72
+ if (meta.length > 0) md.push(meta.join(' | '));
73
+
74
+ return { summary: md.join('\n') };
27
75
  }
@@ -76,6 +76,9 @@ export function useStreamTimeline(events: StreamEvent[], isDone: boolean): Strea
76
76
  // Map nodeId → index in entries[] so we can replace start with complete
77
77
  const nodeEntryIndex = new Map<string, number>();
78
78
  const nodeStarts = new Map<string, number>();
79
+ // Track completed/failed nodes to skip duplicate completion events
80
+ // (FW runtime emits 2x STATUS_CHANGED per node transition)
81
+ const completedNodes = new Set<string>();
79
82
  let idCounter = 0;
80
83
 
81
84
  for (const event of events) {
@@ -95,6 +98,17 @@ export function useStreamTimeline(events: StreamEvent[], isDone: boolean): Strea
95
98
  case 'node-start': {
96
99
  const nodeId = d.nodeId as string;
97
100
  if (nodeId) nodeStarts.set(nodeId, event.timestamp);
101
+
102
+ // If we already have an in-flight entry for this node (duplicate
103
+ // STATUS_CHANGED from the FW runtime), skip the duplicate.
104
+ const existingStartIdx = nodeEntryIndex.get(nodeId);
105
+ if (existingStartIdx != null && entries[existingStartIdx]?.type === 'node-started') {
106
+ break;
107
+ }
108
+
109
+ // Clear completed tracking so retry loops work (node can re-enter)
110
+ completedNodes.delete(nodeId);
111
+
98
112
  const idx = entries.length;
99
113
  nodeEntryIndex.set(nodeId, idx);
100
114
  entries.push({
@@ -111,65 +125,91 @@ export function useStreamTimeline(events: StreamEvent[], isDone: boolean): Strea
111
125
 
112
126
  case 'node-complete': {
113
127
  const nodeId = d.nodeId as string;
128
+ // Skip duplicate completion events for the same node
129
+ if (completedNodes.has(nodeId)) break;
130
+
114
131
  const startTs = nodeStarts.get(nodeId);
115
132
  const duration =
116
133
  (d.durationMs as number) ?? (startTs ? event.timestamp - startTs : undefined);
117
134
  if (nodeId) nodeStarts.delete(nodeId);
118
-
119
- const rawOutputs = d.outputs as Array<{ portLabel: string; value: unknown }> | undefined;
120
-
121
- const completed: TimelineEntry = {
122
- id: `s-${idCounter++}`,
123
- timestamp: new Date(startTs ?? event.timestamp),
124
- type: 'node-completed',
125
- nodeId,
126
- label: (d.label as string) ?? (d.nodeType as string) ?? nodeId ?? 'Node',
127
- duration,
128
- color: d.color as string | undefined,
129
- icon: d.icon as string | undefined,
130
- outputs: rawOutputs && rawOutputs.length > 0 ? rawOutputs : undefined,
131
- };
135
+ completedNodes.add(nodeId);
132
136
 
133
137
  // Replace the node-started entry in-place
134
138
  const existingIdx = nodeEntryIndex.get(nodeId);
135
139
  if (existingIdx != null && entries[existingIdx]) {
136
- entries[existingIdx] = completed;
140
+ const rawOutputs = d.outputs as Array<{ portLabel: string; value: unknown }> | undefined;
141
+ entries[existingIdx] = {
142
+ id: entries[existingIdx]!.id,
143
+ timestamp: new Date(startTs ?? event.timestamp),
144
+ type: 'node-completed',
145
+ nodeId,
146
+ label: (d.label as string) ?? (d.nodeType as string) ?? nodeId ?? 'Node',
147
+ duration,
148
+ color: d.color as string | undefined,
149
+ icon: d.icon as string | undefined,
150
+ outputs: rawOutputs && rawOutputs.length > 0 ? rawOutputs : undefined,
151
+ };
137
152
  nodeEntryIndex.delete(nodeId);
138
153
  } else {
139
- entries.push(completed);
154
+ // No matching start entry — standalone complete (e.g. Start node)
155
+ const rawOutputs = d.outputs as Array<{ portLabel: string; value: unknown }> | undefined;
156
+ entries.push({
157
+ id: `s-${idCounter++}`,
158
+ timestamp: new Date(startTs ?? event.timestamp),
159
+ type: 'node-completed',
160
+ nodeId,
161
+ label: (d.label as string) ?? (d.nodeType as string) ?? nodeId ?? 'Node',
162
+ duration,
163
+ color: d.color as string | undefined,
164
+ icon: d.icon as string | undefined,
165
+ outputs: rawOutputs && rawOutputs.length > 0 ? rawOutputs : undefined,
166
+ });
140
167
  }
141
168
  break;
142
169
  }
143
170
 
144
171
  case 'node-error': {
145
172
  const nodeId = d.nodeId as string;
173
+ if (completedNodes.has(nodeId)) break; // skip duplicate
174
+
146
175
  const startTs = nodeStarts.get(nodeId);
147
176
  const duration =
148
177
  (d.durationMs as number) ?? (startTs ? event.timestamp - startTs : undefined);
149
178
  if (nodeId) nodeStarts.delete(nodeId);
150
-
151
- const rawOutputs = d.outputs as Array<{ portLabel: string; value: unknown }> | undefined;
152
-
153
- const failed: TimelineEntry = {
154
- id: `s-${idCounter++}`,
155
- timestamp: new Date(startTs ?? event.timestamp),
156
- type: 'node-failed',
157
- nodeId,
158
- label: (d.label as string) ?? (d.nodeType as string) ?? nodeId ?? 'Node',
159
- detail: d.error as string | undefined,
160
- duration,
161
- color: d.color as string | undefined,
162
- icon: d.icon as string | undefined,
163
- outputs: rawOutputs && rawOutputs.length > 0 ? rawOutputs : undefined,
164
- };
179
+ completedNodes.add(nodeId);
165
180
 
166
181
  // Replace the node-started entry in-place
167
182
  const existingIdx = nodeEntryIndex.get(nodeId);
168
183
  if (existingIdx != null && entries[existingIdx]) {
169
- entries[existingIdx] = failed;
184
+
185
+ const rawOutputs = d.outputs as Array<{ portLabel: string; value: unknown }> | undefined;
186
+ entries[existingIdx] = {
187
+ id: entries[existingIdx]!.id,
188
+ timestamp: new Date(startTs ?? event.timestamp),
189
+ type: 'node-failed',
190
+ nodeId,
191
+ label: (d.label as string) ?? (d.nodeType as string) ?? nodeId ?? 'Node',
192
+ detail: d.error as string | undefined,
193
+ duration,
194
+ color: d.color as string | undefined,
195
+ icon: d.icon as string | undefined,
196
+ outputs: rawOutputs && rawOutputs.length > 0 ? rawOutputs : undefined,
197
+ };
170
198
  nodeEntryIndex.delete(nodeId);
171
199
  } else {
172
- entries.push(failed);
200
+ const rawOutputs = d.outputs as Array<{ portLabel: string; value: unknown }> | undefined;
201
+ entries.push({
202
+ id: `s-${idCounter++}`,
203
+ timestamp: new Date(startTs ?? event.timestamp),
204
+ type: 'node-failed',
205
+ nodeId,
206
+ label: (d.label as string) ?? (d.nodeType as string) ?? nodeId ?? 'Node',
207
+ detail: d.error as string | undefined,
208
+ duration,
209
+ color: d.color as string | undefined,
210
+ icon: d.icon as string | undefined,
211
+ outputs: rawOutputs && rawOutputs.length > 0 ? rawOutputs : undefined,
212
+ });
173
213
  }
174
214
  break;
175
215
  }