@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 +4 -4
- package/app/api/activity/summary/route.ts +30 -0
- package/components/Dashboard.tsx +6 -2
- package/components/PipelineView.tsx +40 -7
- package/lib/agents/known-models.ts +3 -1
- package/lib/chat/agent-loop.ts +27 -4
- package/lib/chat/input-queue.ts +159 -0
- package/lib/chat/turn-control.ts +30 -2
- package/lib/chat-standalone.ts +40 -37
- package/lib/init.ts +14 -0
- package/lib/pipeline.ts +11 -0
- package/lib/task-manager.ts +30 -0
- package/package.json +1 -1
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
# Forge v0.10.
|
|
1
|
+
# Forge v0.10.57
|
|
2
2
|
|
|
3
|
-
Released: 2026-06-
|
|
3
|
+
Released: 2026-06-10
|
|
4
4
|
|
|
5
|
-
## Changes since v0.10.
|
|
5
|
+
## Changes since v0.10.56
|
|
6
6
|
|
|
7
7
|
|
|
8
|
-
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.
|
|
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(),
|
package/components/Dashboard.tsx
CHANGED
|
@@ -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
|
|
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={
|
|
333
|
-
className={`flex items-start gap-2 text-[10px] w-full text-left rounded px-1 -mx-1 py-0.5 ${
|
|
334
|
-
title={
|
|
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 &&
|
|
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
|
|
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-
|
|
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' },
|
package/lib/chat/agent-loop.ts
CHANGED
|
@@ -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
|
-
//
|
|
766
|
-
//
|
|
767
|
-
|
|
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
|
+
}
|
package/lib/chat/turn-control.ts
CHANGED
|
@@ -31,12 +31,40 @@ function get(sessionId: string): TurnControl {
|
|
|
31
31
|
return c;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
/** Mark a turn as live.
|
|
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
|
-
|
|
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. */
|
package/lib/chat-standalone.ts
CHANGED
|
@@ -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,
|
|
36
|
+
clearSessionMessages, ensureMainSession, forkSession,
|
|
37
37
|
} from './chat/session-store';
|
|
38
|
-
import {
|
|
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
|
-
//
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
225
|
-
//
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
365
|
-
|
|
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
|
}
|
package/lib/task-manager.ts
CHANGED
|
@@ -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