@aion0/forge 0.10.55 → 0.10.57

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/RELEASE_NOTES.md CHANGED
@@ -1,8 +1,8 @@
1
- # Forge v0.10.55
1
+ # Forge v0.10.57
2
2
 
3
- Released: 2026-06-09
3
+ Released: 2026-06-10
4
4
 
5
- ## Changes since v0.10.54
5
+ ## Changes since v0.10.56
6
6
 
7
7
 
8
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.54...v0.10.55
8
+ **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.56...v0.10.57
@@ -13,6 +13,7 @@
13
13
  import { NextResponse } from 'next/server';
14
14
  import { listPipelinesSummary } from '@/lib/pipeline';
15
15
  import { listSchedules } from '@/lib/schedules/store';
16
+ import { listTasksLite } from '@/lib/task-manager';
16
17
 
17
18
  interface RunningRow {
18
19
  id: string;
@@ -41,8 +42,20 @@ interface RecentRow {
41
42
  durationMs: number | null;
42
43
  }
43
44
 
45
+ interface RunningTaskRow {
46
+ id: string;
47
+ project: string;
48
+ prompt_preview: string;
49
+ status: string;
50
+ startedAt: string | null;
51
+ createdAt: string;
52
+ agent: string | null;
53
+ }
54
+
44
55
  interface Summary {
45
56
  running: RunningRow[];
57
+ /** Currently dispatched Claude CLI tasks (separate from pipelines). */
58
+ running_tasks: RunningTaskRow[];
46
59
  upcoming: UpcomingRow[];
47
60
  recent: RecentRow[];
48
61
  generated_at: string;
@@ -125,8 +138,25 @@ export async function GET() {
125
138
  schedule_summary: scheduleSummary(s),
126
139
  }));
127
140
 
141
+ // Running tasks — dispatched via chat's dispatch_task or pipeline node.
142
+ // Lite list (no log / git_diff / result_summary) — same shape constraint
143
+ // as listPipelinesSummary; we just need name + status + project for the
144
+ // activity bar. Top 20 by recency to bound the response.
145
+ const runningTasks: RunningTaskRow[] = listTasksLite('running')
146
+ .slice(0, 20)
147
+ .map((t) => ({
148
+ id: t.id,
149
+ project: t.projectName,
150
+ prompt_preview: (t.prompt || '').replace(/\s+/g, ' ').slice(0, 80),
151
+ status: t.status,
152
+ startedAt: t.startedAt ?? null,
153
+ createdAt: t.createdAt,
154
+ agent: t.agent || null,
155
+ }));
156
+
128
157
  const summary: Summary = {
129
158
  running,
159
+ running_tasks: runningTasks,
130
160
  upcoming,
131
161
  recent,
132
162
  generated_at: new Date().toISOString(),
@@ -121,8 +121,12 @@ export default function Dashboard({ user }: { user: any }) {
121
121
  }
122
122
  // Optional deep-link to a specific pipeline run — used by the extension
123
123
  // Jobs tab when surfacing a dispatch's target pipeline.
124
- const pid = params.get('pipelineId');
125
- if (pid) setPendingPipelineId(pid);
124
+ const pid = params.get('pipelineId') || params.get('pipeline');
125
+ if (pid) { setPendingPipelineId(pid); setViewMode('pipelines' as any); }
126
+ // Same shape for tasks — extension ActivityBar deeplinks
127
+ // ?task=<id> to jump straight to that task in the Tasks view.
128
+ const tid = params.get('taskId') || params.get('task');
129
+ if (tid) { setActiveTaskId(tid); setViewMode('tasks' as any); }
126
130
  }, []);
127
131
  // workspaceProject state kept for forge:open-terminal event compatibility
128
132
  const [workspaceProject, setWorkspaceProject] = useState<{ name: string; path: string } | null>(null);
@@ -215,9 +215,15 @@ const STATUS_COLOR: Record<string, string> = {
215
215
  // this is just a compact status bar to make the loop structure clear.
216
216
  // 2. Iterations — each completed iteration as a foldable card; current
217
217
  // iter shown as a "running ↓" hint pointing to the DAG nodes below.
218
- function ForEachStatePanel({ pipeline, onViewTask }: {
218
+ function ForEachStatePanel({ pipeline, onViewTask, onRetry }: {
219
219
  pipeline: Pipeline;
220
220
  onViewTask?: (taskId: string) => void;
221
+ /** Retry a failed node — wired to /api/pipelines/:id retry-node. Currently
222
+ * the backend resets the node + downstream and (for forEach) rewinds the
223
+ * iteration cursor so the orchestrator picks up from the failed iter and
224
+ * continues through the remaining iterations. Surfaces "only last iter
225
+ * supported" errors from the backend as an alert. */
226
+ onRetry?: (nodeId: string) => void;
221
227
  }) {
222
228
  const fe = pipeline.forEach!;
223
229
  const [openIdx, setOpenIdx] = useState<number | null>(null);
@@ -325,24 +331,47 @@ function ForEachStatePanel({ pipeline, onViewTask }: {
325
331
  <div className="px-3 py-2 space-y-1 border-t border-[var(--border)] bg-[var(--bg-tertiary)]/30">
326
332
  {Object.entries(iter.nodes).map(([nodeId, n]) => {
327
333
  const clickable = !!(n.taskId && onViewTask);
328
- const Tag = clickable ? 'button' : 'div';
334
+ const retriable = !!onRetry && (n.status === 'failed' || n.status === 'cancelled');
335
+ // Row is a div when there's a retry button — buttons can't
336
+ // nest inside buttons. Status icon / "view task" become
337
+ // their own click targets in that case.
338
+ const rowAsButton = clickable && !retriable;
339
+ const Tag = rowAsButton ? 'button' : 'div';
329
340
  return (
330
341
  <Tag
331
342
  key={nodeId}
332
- onClick={clickable ? () => onViewTask!(n.taskId!) : undefined}
333
- className={`flex items-start gap-2 text-[10px] w-full text-left rounded px-1 -mx-1 py-0.5 ${clickable ? 'hover:bg-[var(--bg-secondary)] cursor-pointer' : ''}`}
334
- title={clickable ? `View task ${n.taskId}` : undefined}
343
+ onClick={rowAsButton ? () => onViewTask!(n.taskId!) : undefined}
344
+ className={`flex items-start gap-2 text-[10px] w-full text-left rounded px-1 -mx-1 py-0.5 ${rowAsButton ? 'hover:bg-[var(--bg-secondary)] cursor-pointer' : ''}`}
345
+ title={rowAsButton ? `View task ${n.taskId}` : undefined}
335
346
  >
336
347
  <span className={STATUS_COLOR[n.status] ?? 'text-gray-400'}>
337
348
  {STATUS_ICON[n.status] ?? '?'}
338
349
  </span>
339
350
  <span className="font-mono">{nodeId}</span>
340
- {clickable && <span className="text-[9px] text-[var(--text-secondary)]">↗</span>}
351
+ {clickable && !rowAsButton && (
352
+ <button
353
+ type="button"
354
+ onClick={(e) => { e.stopPropagation(); onViewTask!(n.taskId!); }}
355
+ className="text-[9px] text-[var(--text-secondary)] hover:text-[var(--accent)] underline"
356
+ title={`View task ${n.taskId}`}
357
+ >↗</button>
358
+ )}
359
+ {clickable && rowAsButton && (
360
+ <span className="text-[9px] text-[var(--text-secondary)]">↗</span>
361
+ )}
341
362
  {n.error && (
342
363
  <span className="text-red-400 truncate flex-1" title={n.error}>
343
364
  — {n.error.slice(0, 80)}
344
365
  </span>
345
366
  )}
367
+ {retriable && (
368
+ <button
369
+ type="button"
370
+ onClick={(e) => { e.stopPropagation(); onRetry!(nodeId); }}
371
+ className="ml-auto text-[9px] px-1.5 py-0 border border-yellow-500/50 text-yellow-400 hover:bg-yellow-500/10 rounded"
372
+ title={`Retry ${nodeId} — resets this node and any downstream, and (for forEach) resumes from this iteration through the remaining items. Use this instead of retrying the underlying task, which leaves the pipeline stuck.`}
373
+ >↻ retry</button>
374
+ )}
346
375
  </Tag>
347
376
  );
348
377
  })}
@@ -1554,7 +1583,11 @@ initial_prompt: "{{input.task}}"
1554
1583
  <div className="overflow-y-auto">
1555
1584
  {/* for_each loop: setup + iteration history above the current-iter nodes */}
1556
1585
  {selectedPipeline.forEach && (
1557
- <ForEachStatePanel pipeline={selectedPipeline} onViewTask={onViewTask} />
1586
+ <ForEachStatePanel
1587
+ pipeline={selectedPipeline}
1588
+ onViewTask={onViewTask}
1589
+ onRetry={(nid) => handleRetryNode(selectedPipeline.id, nid)}
1590
+ />
1558
1591
  )}
1559
1592
  <div className="p-4 space-y-2">
1560
1593
  {(() => {
@@ -14,7 +14,7 @@ import type { ModelsRegistry } from '../public-info/types';
14
14
 
15
15
  export const KNOWN_MODELS_FALLBACK: ModelsRegistry = {
16
16
  version: 1,
17
- updatedAt: '2026-05-30',
17
+ updatedAt: '2026-06-10',
18
18
  note: 'Bundled fallback — actual current list lives in forge-public-info/models/registry.json',
19
19
  agents: {
20
20
  'claude-code': {
@@ -22,11 +22,13 @@ export const KNOWN_MODELS_FALLBACK: ModelsRegistry = {
22
22
  default: 'claude-sonnet-4-6',
23
23
  aliases: [
24
24
  { id: 'default', label: 'default (CLI decides)' },
25
+ { id: 'fable', label: 'fable (alias)' },
25
26
  { id: 'sonnet', label: 'sonnet (alias)' },
26
27
  { id: 'opus', label: 'opus (alias)' },
27
28
  { id: 'haiku', label: 'haiku (alias)' },
28
29
  ],
29
30
  models: [
31
+ { id: 'claude-fable-5', label: 'Fable 5', tier: 'premium' },
30
32
  { id: 'claude-opus-4-8', label: 'Opus 4.8', tier: 'premium' },
31
33
  { id: 'claude-sonnet-4-6', label: 'Sonnet 4.6', tier: 'standard', default: true },
32
34
  { id: 'claude-haiku-4-5-20251001', label: 'Haiku 4.5', tier: 'fast' },
@@ -567,6 +567,19 @@ export interface RunTurnArgs {
567
567
 
568
568
  export async function runTurn(args: RunTurnArgs): Promise<{ ok: boolean; error?: string }> {
569
569
  const cb = args.callbacks?.onEvent ?? (() => {});
570
+
571
+ // Claim the turn-control flag FIRST — before any early-return path
572
+ // (session-not-found, provider-error). The outer try/finally below
573
+ // guarantees endTurn runs in EVERY exit path, including early returns
574
+ // and uncaught throws, so the running flag never gets stuck true.
575
+ //
576
+ // The caller (enqueueChatInput) may have already claimed via
577
+ // tryBeginTurn — beginTurn is idempotent on running:true and doesn't
578
+ // wipe notes that may have arrived in the claim→here window.
579
+ beginTurn(args.sessionId);
580
+
581
+ try {
582
+
570
583
  const session = getSession(args.sessionId);
571
584
  if (!session) {
572
585
  cb({ type: 'error', data: { error: `session not found: ${args.sessionId}` } });
@@ -762,10 +775,11 @@ export async function runTurn(args: RunTurnArgs): Promise<{ ok: boolean; error?:
762
775
  let lastStop = '';
763
776
  let assistantBlocksAccum: ContentBlock[] = [];
764
777
 
765
- // Mark this turn live so the user can abort it / inject notes mid-flight
766
- // (see turn-control.ts). Cleared in the finally below.
767
- beginTurn(args.sessionId);
768
-
778
+ // beginTurn already ran at function entry (see top of runTurn). The
779
+ // try/finally below is for the LLM-streaming error path — keep it
780
+ // so fetch errors still get fanned out as 'error' events. endTurn is
781
+ // handled by the OUTER try/finally so removing it from this inner
782
+ // finally is intentional (avoids redundant calls).
769
783
  try {
770
784
  while (iter < MAX_ITERATIONS) {
771
785
  iter += 1;
@@ -1028,7 +1042,16 @@ export async function runTurn(args: RunTurnArgs): Promise<{ ok: boolean; error?:
1028
1042
  });
1029
1043
  cb({ type: 'error', data: { error: msg } });
1030
1044
  return { ok: false, error: msg };
1045
+ }
1046
+ // Inner try (LLM streaming) ends here — no finally; the OUTER
1047
+ // finally below owns endTurn so it runs even when an early return
1048
+ // above (session-not-found / provider-error) skips the inner loop.
1049
+
1031
1050
  } finally {
1051
+ // Outer finally — runs on ALL exit paths, including the early
1052
+ // returns at session-not-found and provider-error, and any throw
1053
+ // anywhere in the function. Guarantees the turn-control running
1054
+ // flag is always cleared so future inputs can start fresh turns.
1032
1055
  endTurn(args.sessionId);
1033
1056
  }
1034
1057
  }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Chat input queue — the single routing/merge layer for all external
3
+ * inputs to a chat session.
4
+ *
5
+ * Why this file exists. Before, every input source had to re-implement
6
+ * the "if a turn is already running, merge into it instead of forking
7
+ * a new turn" gate (see handleMessagePost vs the watch runChat callback).
8
+ * Easy to forget — and forgetting it caused the 2026-06-09 bug where a
9
+ * watch firing mid-turn produced duplicate trigger_pipeline calls (commit
10
+ * 0d48569 patched that one path). User: "你应该把所有的消息输入别是 chat
11
+ * 放在一个队列通道中,然后合并处理".
12
+ *
13
+ * Now there's exactly ONE entry point. All callers — user POST /messages,
14
+ * watch on_done/on_fail injects, schedule action announcements, future
15
+ * Slack/IDE/cron sources — call enqueueChatInput. The function decides
16
+ * whether to (a) merge into the running turn as a note, (b) start a new
17
+ * turn, or (c) just persist+broadcast an announcement without invoking
18
+ * the LLM.
19
+ *
20
+ * Per-session queueing already lives in turn-control.ts (notes Map keyed
21
+ * by sessionId). When multi-conversation features land later, they just
22
+ * spawn more sessions — the same merge semantics apply per-session
23
+ * without code changes here.
24
+ */
25
+
26
+ import { appendMessage } from './session-store';
27
+ import { runTurn, type AgentEvent } from './agent-loop';
28
+ import type { ContentBlock } from './types';
29
+ import { isTurnRunning, addNote, tryBeginTurn } from './turn-control';
30
+
31
+ /** Where the input came from. Pure metadata — used for logs + decisions
32
+ * about default behavior (e.g. watch text might want a tag prefix). */
33
+ export type InputSource = 'user' | 'watch' | 'schedule' | 'bridge' | 'mcp' | 'unknown';
34
+
35
+ export interface EnqueueOpts {
36
+ sessionId: string;
37
+ /** For 'turn' mode: the raw user-side text the agent should see.
38
+ * For 'announce' mode: optional shortcut — wrapped in [{type:'text'}]. */
39
+ text?: string;
40
+ /** For 'announce' mode: pre-formed message blocks. Wins over `text`. */
41
+ blocks?: ContentBlock[];
42
+ /** Announce role. Default 'assistant' — a system-emitted notice. */
43
+ role?: 'user' | 'assistant';
44
+ /**
45
+ * 'turn' — drive the agent. Either start a new turn or merge into
46
+ * an already-running one as a note (LLM sees on next
47
+ * iteration boundary). MOST CALLERS USE THIS.
48
+ * 'announce' — push a pre-formed message into the thread, no agent
49
+ * invocation. Used by schedule chat-action so its
50
+ * completion notice shows up without triggering a reply.
51
+ */
52
+ mode: 'turn' | 'announce';
53
+ source: InputSource;
54
+ /** Sink for SSE events fanned out to subscribers. Required for 'turn'
55
+ * mode (or the UI sees no streaming). 'announce' uses it to push the
56
+ * message_saved event so open tabs render the new message live. */
57
+ onEvent?: (e: AgentEvent) => void;
58
+ }
59
+
60
+ export interface EnqueueResult {
61
+ ok: boolean;
62
+ /** 'started' — fresh runTurn kicked off
63
+ * 'merged' — text queued as a note for the in-flight turn
64
+ * 'announced' — message persisted + broadcast, no turn
65
+ * 'rejected' — input invalid (empty text in turn mode, etc.) */
66
+ status: 'started' | 'merged' | 'announced' | 'rejected';
67
+ /** Populated by 'announced' — the persisted message's id, so the
68
+ * /inject HTTP response can return it (existing API contract). */
69
+ messageId?: string;
70
+ reason?: string;
71
+ }
72
+
73
+ /**
74
+ * The single entry point. Routes by mode + isTurnRunning state.
75
+ *
76
+ * Sync (returns immediately). For 'turn' mode it kicks off runTurn in
77
+ * the background — caller doesn't await; events flow through onEvent.
78
+ */
79
+ export function enqueueChatInput(opts: EnqueueOpts): EnqueueResult {
80
+ if (opts.mode === 'announce') {
81
+ // Persist the message without invoking the LLM. role defaults to
82
+ // 'assistant' (system-emitted notice). Caller supplies blocks
83
+ // directly or a plain text shortcut.
84
+ const blocks: ContentBlock[] = opts.blocks
85
+ ?? [{ type: 'text', text: (opts.text ?? '') } as ContentBlock];
86
+ if (blocks.length === 0) {
87
+ return { ok: false, status: 'rejected', reason: 'announce requires blocks or text' };
88
+ }
89
+ const saved = appendMessage({
90
+ session_id: opts.sessionId,
91
+ role: opts.role || 'assistant',
92
+ blocks,
93
+ });
94
+ opts.onEvent?.({ type: 'message_saved', message_id: saved.id, data: saved });
95
+ return { ok: true, status: 'announced', messageId: saved.id };
96
+ }
97
+
98
+ // 'turn' mode — agent must drive a response.
99
+ const text = (opts.text || '').trim();
100
+ if (!text) {
101
+ return { ok: false, status: 'rejected', reason: 'turn mode requires non-empty text' };
102
+ }
103
+
104
+ // If a turn is already in flight on this session, queue the text as
105
+ // a note for that running turn. The loop splices each note in at the
106
+ // next iteration boundary (see agent-loop.ts:790). This is the merge
107
+ // semantic — replaces every caller having to gate isTurnRunning itself.
108
+ if (isTurnRunning(opts.sessionId)) {
109
+ const queued = addNote(opts.sessionId, text);
110
+ if (queued) return { ok: true, status: 'merged' };
111
+ // Tiny race: isTurnRunning was true but endTurn fired between the
112
+ // check and addNote. Fall through to start a fresh turn.
113
+ }
114
+
115
+ // Atomic claim — closes the gap between this check and the in-loop
116
+ // beginTurn() call inside runTurn. runTurn does ~100ms of async setup
117
+ // (appendMessage, listMessagesCapped, provider resolve, SSE pushes)
118
+ // BEFORE its beginTurn runs; without claiming here, a second input
119
+ // arriving during that window would also see isTurnRunning=false and
120
+ // fork another runTurn. Both then race on the same chat history and
121
+ // produce duplicate tool calls (observed 2026-06-10: watch + user
122
+ // input → two save_tmp_file calls, two LLM replies).
123
+ //
124
+ // If tryBeginTurn returns false, another caller claimed first within
125
+ // the same JS tick — merge as a note into THAT turn instead. Cheap +
126
+ // correct: addNote on a now-running session always succeeds.
127
+ const claimed = tryBeginTurn(opts.sessionId);
128
+ if (!claimed) {
129
+ addNote(opts.sessionId, text);
130
+ return { ok: true, status: 'merged' };
131
+ }
132
+
133
+ // Claimed — start a fresh one. Fire-
134
+ // and-forget; errors land on the onEvent stream as 'error' events.
135
+ // Two failure modes:
136
+ // (a) runTurn throws (rejected Promise) — caught by .catch.
137
+ // (b) runTurn resolves with {ok:false, error:'...'} — handled in
138
+ // the .then; this case happens in agent-loop's known-error
139
+ // paths (context budget exhausted / orphan tool-use trim /
140
+ // fetch failure with adapter detail). Missing this branch
141
+ // would leave the UI hanging with no error indicator.
142
+ const startedAt = Date.now();
143
+ const onEvent = opts.onEvent;
144
+ void runTurn({
145
+ sessionId: opts.sessionId,
146
+ userText: text,
147
+ callbacks: { onEvent: onEvent || (() => {}) },
148
+ }).then((r) => {
149
+ if (!r.ok) {
150
+ onEvent?.({ type: 'error', data: { error: r.error || 'turn failed' } });
151
+ }
152
+ const ms = Date.now() - startedAt;
153
+ console.log(`[chat] turn ${opts.sessionId.slice(0, 8)} (${opts.source}) done in ${ms}ms ok=${r.ok}`);
154
+ }).catch((err) => {
155
+ console.error(`[input-queue] runTurn threw (source=${opts.source}):`, (err as Error).message);
156
+ onEvent?.({ type: 'error', data: { error: (err as Error).message } });
157
+ });
158
+ return { ok: true, status: 'started' };
159
+ }
@@ -31,12 +31,40 @@ function get(sessionId: string): TurnControl {
31
31
  return c;
32
32
  }
33
33
 
34
- /** Mark a turn as live. Resets abort + drains stale notes from any prior turn. */
34
+ /** Mark a turn as live. Called by agent-loop at the top of runTurn.
35
+ * Sets running:true + aborted:false. Does NOT clear notes — endTurn
36
+ * is the one that clears them at completion. Important: if a turn was
37
+ * claimed via tryBeginTurn in enqueueChatInput and a note arrived in
38
+ * the async window before runTurn reached this point, the note must
39
+ * survive to be picked up at the first iteration boundary. Wiping
40
+ * here would silently drop user input. */
35
41
  export function beginTurn(sessionId: string): void {
36
42
  const c = get(sessionId);
37
43
  c.running = true;
38
44
  c.aborted = false;
39
- c.notes = [];
45
+ // notes intentionally preserved — see comment above.
46
+ }
47
+
48
+ /** Atomic claim — if no turn is running, mark running and return true.
49
+ * If a turn is already running, return false (caller should merge as note).
50
+ * Synchronous — closes the race window between "check isTurnRunning" and
51
+ * runTurn actually entering its loop and calling beginTurn. Without this,
52
+ * two enqueueChatInput calls arriving while runTurn is still in its async
53
+ * setup phase BOTH see isTurnRunning=false and BOTH fork a turn — exactly
54
+ * the duplicate-turn bug observed on 2026-06-10 (watch fired, user typed
55
+ * immediately after, both got separate runTurns and produced doubled
56
+ * tool calls). */
57
+ export function tryBeginTurn(sessionId: string): boolean {
58
+ const c = get(sessionId);
59
+ if (c.running) return false;
60
+ c.running = true;
61
+ c.aborted = false;
62
+ // notes intentionally NOT cleared here — any addNote that landed in
63
+ // the race window before claim is valid input for THIS new turn.
64
+ // beginTurn (called by agent-loop on entry) will set notes=[] only if
65
+ // this is the very first turn; otherwise the prior endTurn already
66
+ // cleared them.
67
+ return true;
40
68
  }
41
69
 
42
70
  /** Turn finished (normally or via abort) — clear all transient state. */
@@ -33,10 +33,11 @@
33
33
  import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
34
34
  import {
35
35
  createSession, getSession, listSessions, updateSession, deleteSession, listMessages,
36
- clearSessionMessages, ensureMainSession, forkSession, appendMessage,
36
+ clearSessionMessages, ensureMainSession, forkSession,
37
37
  } from './chat/session-store';
38
- import { runTurn, type AgentEvent } from './chat/agent-loop';
38
+ import { type AgentEvent } from './chat/agent-loop';
39
39
  import { requestAbort, addNote, isTurnRunning } from './chat/turn-control';
40
+ import { enqueueChatInput } from './chat/input-queue';
40
41
  import { bridgePush } from './chat/bridge-client';
41
42
  import { startWatchRunner } from './watch/watch-runner';
42
43
 
@@ -174,36 +175,21 @@ async function handleMessagePost(req: IncomingMessage, res: ServerResponse, id:
174
175
  const text = String(body?.text || '').trim();
175
176
  if (!text) return sendJson(res, 400, { error: 'text is required' });
176
177
 
177
- // Merge instead of forking: if a turn is already running on this session,
178
- // queue the text as a note for the running turn instead of starting a
179
- // second concurrent runTurn. Two parallel loops on the same session
180
- // (e.g. extension + webchat both sending) would otherwise interleave
181
- // tool calls and share turn-control state — one abort would stop both,
182
- // and the user sees the same task twice. This makes the merge happen
183
- // server-side so it doesn't depend on which client is sending.
184
- if (isTurnRunning(id)) {
185
- const queued = addNote(id, text);
186
- if (queued) {
187
- return sendJson(res, 202, { accepted: true, merged: true, topic: `chat:${id}` });
188
- }
189
- // Tiny race window: isTurnRunning was true but turn ended before we
190
- // queued. Fall through to start a fresh turn with this text.
191
- }
192
-
193
- const startedAt = Date.now();
194
- void runTurn({
178
+ // Route through the single input queue handles isTurnRunning merge
179
+ // (so extension + webchat sending on same session collapse into one
180
+ // turn) without this handler caring how. See lib/chat/input-queue.ts.
181
+ const r = enqueueChatInput({
195
182
  sessionId: id,
196
- userText: text,
197
- callbacks: { onEvent: (e: AgentEvent) => fanoutEvent(id, e) },
198
- }).then((r) => {
199
- if (!r.ok) fanoutEvent(id, { type: 'error', data: { error: r.error || 'unknown' } });
200
- console.log(`[chat] turn ${id.slice(0, 8)} done in ${Date.now() - startedAt}ms ok=${r.ok}`);
201
- }).catch((err) => {
202
- fanoutEvent(id, { type: 'error', data: { error: (err as Error).message } });
203
- console.error('[chat] turn error', err);
183
+ text,
184
+ mode: 'turn',
185
+ source: 'user',
186
+ onEvent: (e) => fanoutEvent(id, e),
187
+ });
188
+ sendJson(res, 202, {
189
+ accepted: true,
190
+ merged: r.status === 'merged',
191
+ topic: `chat:${id}`,
204
192
  });
205
-
206
- sendJson(res, 202, { accepted: true, topic: `chat:${id}` });
207
193
  }
208
194
 
209
195
  /**
@@ -221,11 +207,18 @@ async function handleInjectMessage(req: IncomingMessage, res: ServerResponse, id
221
207
  const role: 'user' | 'assistant' = body?.role === 'user' ? 'user' : 'assistant';
222
208
  const blocks = Array.isArray(body?.blocks) ? body.blocks : null;
223
209
  if (!blocks || blocks.length === 0) return sendJson(res, 400, { error: 'blocks[] required' });
224
- const saved = appendMessage({ session_id: id, role, blocks });
225
- // Payload shape must match what agent-loop emits extension's
226
- // messageFromServer(event.data) reads .id/.role/.blocks/.ts directly.
227
- fanoutEvent(id, { type: 'message_saved', message_id: saved.id, data: saved });
228
- sendJson(res, 200, { ok: true, message_id: saved.id });
210
+ // Announce mode persist + SSE push, no agent invocation. Goes
211
+ // through the same input-queue for consistency (one entry point).
212
+ const r = enqueueChatInput({
213
+ sessionId: id,
214
+ blocks,
215
+ role,
216
+ mode: 'announce',
217
+ source: 'schedule',
218
+ onEvent: (e) => fanoutEvent(id, e),
219
+ });
220
+ if (!r.ok) return sendJson(res, 400, { error: r.reason || 'enqueue failed' });
221
+ sendJson(res, 200, { ok: true, message_id: r.messageId });
229
222
  }
230
223
 
231
224
  // Abort the in-flight tool-call loop for a session. The loop breaks at its
@@ -361,8 +354,18 @@ httpServer.listen(PORT, '127.0.0.1', () => {
361
354
  startWatchRunner({
362
355
  onProgress: (sessionId, payload) => fanoutEvent(sessionId, { type: 'watch_status', data: payload }),
363
356
  runChat: (sessionId, text) => {
364
- void runTurn({ sessionId, userText: text, callbacks: { onEvent: (e) => fanoutEvent(sessionId, e) } })
365
- .catch((err) => console.error('[watch] runChat failed', (err as Error).message));
357
+ // Watch-triggered chat input. Routes through the single input
358
+ // queue automatically merges into a running turn (as a note)
359
+ // or starts a fresh one. Without going through enqueueChatInput
360
+ // a watch firing mid-turn would spawn a concurrent runTurn and
361
+ // produce duplicate tool calls (regression seen on 2026-06-09).
362
+ enqueueChatInput({
363
+ sessionId,
364
+ text,
365
+ mode: 'turn',
366
+ source: 'watch',
367
+ onEvent: (e) => fanoutEvent(sessionId, e),
368
+ });
366
369
  },
367
370
  });
368
371
  });
package/lib/init.ts CHANGED
@@ -182,6 +182,20 @@ export function ensureInitialized() {
182
182
  }, 60 * 60 * 1000);
183
183
  } catch {}
184
184
 
185
+ // Reconcile orphaned tasks — any DB row at status='running' or 'queued'
186
+ // at startup is by definition stuck (its parent next-server process is
187
+ // gone; we're booting the new one). Without this, the Activity panel
188
+ // shows zombie tasks indefinitely and dispatch_task can collide with
189
+ // stale project locks. Idempotent — second boot finds zero.
190
+ time('reconcileOrphanedTasks', () => {
191
+ try {
192
+ const { reconcileOrphanedTasks } = require('./task-manager');
193
+ reconcileOrphanedTasks();
194
+ } catch (e) {
195
+ console.warn('[init] reconcileOrphanedTasks failed:', (e as Error).message);
196
+ }
197
+ });
198
+
185
199
  // Usage scanner — defer to next tick so it doesn't block ensureInitialized().
186
200
  // On a host with hundreds of project dirs in ~/.claude/projects/, the
187
201
  // synchronous readdirSync + statSync loop can take 5-10s; running it on
package/lib/pipeline.ts CHANGED
@@ -1589,6 +1589,17 @@ export async function retryNode(pipelineId: string, nodeId: string): Promise<{ o
1589
1589
  // underlying task is dead); cancelled covers user-cancelled pipelines
1590
1590
  // where the user later wants to resume from the cancelled node.
1591
1591
  // pending/done/skipped are still misclicks.
1592
+ //
1593
+ // 'skipped' specifically is NOT retriable here — in forEach pipelines
1594
+ // orchestrator marks a per-iter step 'skipped' to indicate "this
1595
+ // iteration failed but we're continuing to the next item" (not the
1596
+ // usual "upstream-failed → skip downstream" semantic). Letting retry
1597
+ // touch a skipped node would risk re-firing iteration logic that the
1598
+ // orchestrator already decided to abandon, terminating the whole
1599
+ // for_each loop. If a real upstream-failure-cascade arises and the
1600
+ // user needs to retry a skipped downstream, the right path is to
1601
+ // retry the failed root explicitly — its BFS-downstream reset will
1602
+ // pull this node back to pending.
1592
1603
  if (nodeState.status !== 'failed' && nodeState.status !== 'running' && nodeState.status !== 'cancelled') {
1593
1604
  return { ok: false, error: `node is in status '${nodeState.status}' — only failed, running, or cancelled nodes can be retried` };
1594
1605
  }
@@ -123,6 +123,36 @@ export function getTask(id: string): Task | null {
123
123
  return rowToTask(row);
124
124
  }
125
125
 
126
+ /**
127
+ * Reconcile orphaned 'running' tasks. Tasks spawn child processes
128
+ * owned by next-server (lib/claude-process); when next-server exits
129
+ * (forge restart / crash / stop), those processes die but the DB row
130
+ * stays at status='running' forever. Result: Activity panel /
131
+ * /api/activity/summary keeps showing zombie tasks the user never
132
+ * started; new dispatches can collide with stuck project locks.
133
+ *
134
+ * Called once at init.ts startup. Any row still showing 'running' is
135
+ * by definition orphaned — its parent next-server process is gone
136
+ * (otherwise we wouldn't be in startup). Mark all as failed with a
137
+ * clear error so the user knows it was a restart, not a real failure.
138
+ *
139
+ * Idempotent — second run finds zero rows to update.
140
+ */
141
+ export function reconcileOrphanedTasks(): number {
142
+ const r = db().prepare(`
143
+ UPDATE tasks
144
+ SET status = 'failed',
145
+ error = COALESCE(NULLIF(error, ''), 'orphaned by server restart — task process did not survive restart'),
146
+ completed_at = datetime('now')
147
+ WHERE status IN ('running', 'queued')
148
+ `).run();
149
+ const n = (r.changes as number) || 0;
150
+ if (n > 0) {
151
+ console.log(`[task-manager] reconciled ${n} orphaned task(s) (running→failed)`);
152
+ }
153
+ return n;
154
+ }
155
+
126
156
  export function listTasks(status?: TaskStatus): Task[] {
127
157
  let query = 'SELECT * FROM tasks';
128
158
  const params: string[] = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.10.55",
3
+ "version": "0.10.57",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {