@aion0/forge 0.4.16 → 0.5.1
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/README.md +27 -2
- package/RELEASE_NOTES.md +21 -14
- package/app/api/agents/route.ts +17 -0
- package/app/api/delivery/[id]/route.ts +62 -0
- package/app/api/delivery/route.ts +40 -0
- package/app/api/mobile-chat/route.ts +13 -7
- package/app/api/monitor/route.ts +10 -6
- package/app/api/pipelines/[id]/route.ts +16 -3
- package/app/api/tasks/route.ts +2 -1
- package/app/api/workspace/[id]/agents/route.ts +35 -0
- package/app/api/workspace/[id]/memory/route.ts +23 -0
- package/app/api/workspace/[id]/smith/route.ts +22 -0
- package/app/api/workspace/[id]/stream/route.ts +28 -0
- package/app/api/workspace/route.ts +100 -0
- package/app/global-error.tsx +10 -4
- package/app/icon.ico +0 -0
- package/app/layout.tsx +2 -2
- package/app/login/LoginForm.tsx +96 -0
- package/app/login/page.tsx +7 -98
- package/app/page.tsx +2 -2
- package/bin/forge-server.mjs +13 -1
- package/check-forge-status.sh +9 -0
- package/components/ConversationEditor.tsx +411 -0
- package/components/ConversationGraphView.tsx +347 -0
- package/components/ConversationTerminalView.tsx +303 -0
- package/components/Dashboard.tsx +36 -39
- package/components/DashboardWrapper.tsx +9 -0
- package/components/DeliveryFlowEditor.tsx +491 -0
- package/components/DeliveryList.tsx +230 -0
- package/components/DeliveryWorkspace.tsx +589 -0
- package/components/DocTerminal.tsx +10 -2
- package/components/DocsViewer.tsx +10 -2
- package/components/HelpTerminal.tsx +11 -6
- package/components/InlinePipelineView.tsx +111 -0
- package/components/MobileView.tsx +20 -0
- package/components/MonitorPanel.tsx +9 -4
- package/components/NewTaskModal.tsx +32 -0
- package/components/PipelineEditor.tsx +49 -6
- package/components/PipelineView.tsx +482 -64
- package/components/ProjectDetail.tsx +314 -56
- package/components/ProjectManager.tsx +49 -4
- package/components/SessionView.tsx +27 -13
- package/components/SettingsModal.tsx +790 -124
- package/components/SkillsPanel.tsx +31 -8
- package/components/TaskBoard.tsx +3 -0
- package/components/WebTerminal.tsx +257 -43
- package/components/WorkspaceTree.tsx +221 -0
- package/components/WorkspaceView.tsx +2245 -0
- package/install.sh +2 -2
- package/lib/agents/claude-adapter.ts +104 -0
- package/lib/agents/generic-adapter.ts +64 -0
- package/lib/agents/index.ts +242 -0
- package/lib/agents/types.ts +70 -0
- package/lib/artifacts.ts +106 -0
- package/lib/delivery.ts +787 -0
- package/lib/forge-skills/forge-inbox.md +37 -0
- package/lib/forge-skills/forge-send.md +40 -0
- package/lib/forge-skills/forge-status.md +32 -0
- package/lib/forge-skills/forge-workspace-sync.md +37 -0
- package/lib/help-docs/00-overview.md +7 -1
- package/lib/help-docs/01-settings.md +159 -2
- package/lib/help-docs/05-pipelines.md +89 -0
- package/lib/help-docs/07-projects.md +35 -1
- package/lib/help-docs/11-workspace.md +254 -0
- package/lib/help-docs/CLAUDE.md +7 -2
- package/lib/init.ts +60 -10
- package/lib/pipeline.ts +537 -1
- package/lib/settings.ts +115 -22
- package/lib/skills.ts +249 -372
- package/lib/task-manager.ts +113 -33
- package/lib/telegram-bot.ts +33 -1
- package/lib/workspace/__tests__/state-machine.test.ts +388 -0
- package/lib/workspace/__tests__/workspace.test.ts +311 -0
- package/lib/workspace/agent-bus.ts +416 -0
- package/lib/workspace/agent-worker.ts +667 -0
- package/lib/workspace/backends/api-backend.ts +262 -0
- package/lib/workspace/backends/cli-backend.ts +479 -0
- package/lib/workspace/index.ts +82 -0
- package/lib/workspace/manager.ts +136 -0
- package/lib/workspace/orchestrator.ts +1914 -0
- package/lib/workspace/persistence.ts +310 -0
- package/lib/workspace/presets.ts +170 -0
- package/lib/workspace/skill-installer.ts +188 -0
- package/lib/workspace/smith-memory.ts +498 -0
- package/lib/workspace/types.ts +231 -0
- package/lib/workspace/watch-manager.ts +288 -0
- package/lib/workspace-standalone.ts +814 -0
- package/middleware.ts +1 -0
- package/next-env.d.ts +1 -1
- package/package.json +4 -1
- package/src/config/index.ts +12 -1
- package/src/core/db/database.ts +1 -0
- package/start.sh +7 -0
|
@@ -0,0 +1,667 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AgentWorker — manages the lifecycle of a single workspace agent.
|
|
3
|
+
*
|
|
4
|
+
* Responsibilities:
|
|
5
|
+
* - Multi-step execution with context accumulation
|
|
6
|
+
* - Pause / resume / stop
|
|
7
|
+
* - Bus message injection between steps
|
|
8
|
+
* - Event emission for UI and orchestrator
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { EventEmitter } from 'node:events';
|
|
12
|
+
import type {
|
|
13
|
+
WorkspaceAgentConfig,
|
|
14
|
+
AgentState,
|
|
15
|
+
TaskStatus,
|
|
16
|
+
SmithStatus,
|
|
17
|
+
AgentMode,
|
|
18
|
+
AgentBackend,
|
|
19
|
+
WorkerEvent,
|
|
20
|
+
Artifact,
|
|
21
|
+
BusMessage,
|
|
22
|
+
DaemonWakeReason,
|
|
23
|
+
} from './types';
|
|
24
|
+
import type { TaskLogEntry } from '@/src/types';
|
|
25
|
+
|
|
26
|
+
export interface AgentWorkerOptions {
|
|
27
|
+
config: WorkspaceAgentConfig;
|
|
28
|
+
backend: AgentBackend;
|
|
29
|
+
projectPath: string;
|
|
30
|
+
workspaceId?: string;
|
|
31
|
+
initialTaskStatus?: 'idle' | 'running' | 'done' | 'failed';
|
|
32
|
+
// Bus communication callbacks (injected by orchestrator)
|
|
33
|
+
onBusSend?: (to: string, content: string) => void;
|
|
34
|
+
onBusRequest?: (to: string, question: string) => Promise<string>;
|
|
35
|
+
peerAgentIds?: string[];
|
|
36
|
+
// Message status callback — smith marks its own messages
|
|
37
|
+
onMessageDone?: (messageId: string) => void;
|
|
38
|
+
onMessageFailed?: (messageId: string) => void;
|
|
39
|
+
// Memory (injected by orchestrator)
|
|
40
|
+
memoryContext?: string;
|
|
41
|
+
onMemoryUpdate?: (stepResults: string[]) => void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class AgentWorker extends EventEmitter {
|
|
45
|
+
readonly config: WorkspaceAgentConfig;
|
|
46
|
+
private state: AgentState;
|
|
47
|
+
private backend: AgentBackend;
|
|
48
|
+
private projectPath: string;
|
|
49
|
+
private workspaceId?: string;
|
|
50
|
+
private busCallbacks: {
|
|
51
|
+
onBusSend?: (to: string, content: string) => void;
|
|
52
|
+
onBusRequest?: (to: string, question: string) => Promise<string>;
|
|
53
|
+
peerAgentIds?: string[];
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// Control
|
|
57
|
+
private abortController: AbortController | null = null;
|
|
58
|
+
private paused = false;
|
|
59
|
+
private pauseResolve: (() => void) | null = null;
|
|
60
|
+
|
|
61
|
+
// Bus messages queued between steps
|
|
62
|
+
private pendingMessages: TaskLogEntry[] = [];
|
|
63
|
+
|
|
64
|
+
// Memory
|
|
65
|
+
private memoryContext?: string;
|
|
66
|
+
private onMemoryUpdate?: (stepResults: string[]) => void;
|
|
67
|
+
private stepResults: string[] = [];
|
|
68
|
+
|
|
69
|
+
// Daemon mode
|
|
70
|
+
private wakeResolve: ((reason: DaemonWakeReason) => void) | null = null;
|
|
71
|
+
private pendingWake: DaemonWakeReason | null = null;
|
|
72
|
+
private daemonRetryCount = 0;
|
|
73
|
+
private currentMessageId: string | null = null; // ID of the bus message being processed
|
|
74
|
+
private onMessageDone?: (messageId: string) => void;
|
|
75
|
+
private onMessageFailed?: (messageId: string) => void;
|
|
76
|
+
|
|
77
|
+
constructor(opts: AgentWorkerOptions) {
|
|
78
|
+
super();
|
|
79
|
+
this.config = opts.config;
|
|
80
|
+
this.backend = opts.backend;
|
|
81
|
+
this.projectPath = opts.projectPath;
|
|
82
|
+
this.busCallbacks = {
|
|
83
|
+
onBusSend: opts.onBusSend,
|
|
84
|
+
onBusRequest: opts.onBusRequest,
|
|
85
|
+
peerAgentIds: opts.peerAgentIds,
|
|
86
|
+
};
|
|
87
|
+
this.workspaceId = opts.workspaceId;
|
|
88
|
+
this.memoryContext = opts.memoryContext;
|
|
89
|
+
this.onMemoryUpdate = opts.onMemoryUpdate;
|
|
90
|
+
this.onMessageDone = opts.onMessageDone;
|
|
91
|
+
this.onMessageFailed = opts.onMessageFailed;
|
|
92
|
+
this.state = {
|
|
93
|
+
smithStatus: 'down',
|
|
94
|
+
mode: 'auto',
|
|
95
|
+
taskStatus: opts.initialTaskStatus || 'idle',
|
|
96
|
+
history: [],
|
|
97
|
+
artifacts: [],
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ─── Public API ──────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Execute all steps starting from `startStep`.
|
|
105
|
+
* For recovery, pass `lastCheckpoint + 1`.
|
|
106
|
+
*/
|
|
107
|
+
async execute(startStep = 0, upstreamContext?: string): Promise<void> {
|
|
108
|
+
const { steps } = this.config;
|
|
109
|
+
if (steps.length === 0) {
|
|
110
|
+
this.setTaskStatus('done');
|
|
111
|
+
this.emitEvent({ type: 'done', agentId: this.config.id, summary: 'No steps defined' });
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Prepend memory to upstream context
|
|
116
|
+
if (this.memoryContext) {
|
|
117
|
+
upstreamContext = upstreamContext
|
|
118
|
+
? this.memoryContext + '\n\n---\n\n' + upstreamContext
|
|
119
|
+
: this.memoryContext;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
this.stepResults = [];
|
|
123
|
+
this.abortController = new AbortController();
|
|
124
|
+
this.setTaskStatus('running');
|
|
125
|
+
this.state.startedAt = Date.now();
|
|
126
|
+
|
|
127
|
+
for (let i = startStep; i < steps.length; i++) {
|
|
128
|
+
// Check pause
|
|
129
|
+
await this.waitIfPaused();
|
|
130
|
+
|
|
131
|
+
// Check abort
|
|
132
|
+
if (this.abortController.signal.aborted) {
|
|
133
|
+
console.log(`[worker] ${this.config.label}: abort detected before step ${i} (signal already aborted)`);
|
|
134
|
+
this.markMessageFailed();
|
|
135
|
+
this.setTaskStatus('failed', 'Interrupted');
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const step = steps[i];
|
|
140
|
+
this.state.currentStep = i;
|
|
141
|
+
this.emitEvent({ type: 'step', agentId: this.config.id, stepIndex: i, stepLabel: step.label });
|
|
142
|
+
|
|
143
|
+
// Consume pending bus messages → append to history as context
|
|
144
|
+
if (this.pendingMessages.length > 0) {
|
|
145
|
+
for (const msg of this.pendingMessages) {
|
|
146
|
+
this.state.history.push(msg);
|
|
147
|
+
}
|
|
148
|
+
this.pendingMessages = [];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
const result = await this.backend.executeStep({
|
|
153
|
+
config: this.config,
|
|
154
|
+
step,
|
|
155
|
+
stepIndex: i,
|
|
156
|
+
history: this.state.history,
|
|
157
|
+
projectPath: this.projectPath,
|
|
158
|
+
workspaceId: this.workspaceId,
|
|
159
|
+
upstreamContext: i === startStep ? upstreamContext : undefined,
|
|
160
|
+
onBusSend: this.busCallbacks.onBusSend,
|
|
161
|
+
onBusRequest: this.busCallbacks.onBusRequest,
|
|
162
|
+
peerAgentIds: this.busCallbacks.peerAgentIds,
|
|
163
|
+
abortSignal: this.abortController.signal,
|
|
164
|
+
onLog: (entry) => {
|
|
165
|
+
this.state.history.push(entry);
|
|
166
|
+
this.emitEvent({ type: 'log', agentId: this.config.id, entry });
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// Validate result — detect if the agent hit an error but backend didn't catch it
|
|
171
|
+
const failureCheck = detectStepFailure(result.response);
|
|
172
|
+
if (failureCheck) {
|
|
173
|
+
throw new Error(failureCheck);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Record the assistant's final response for this step
|
|
177
|
+
this.state.history.push({
|
|
178
|
+
type: 'result',
|
|
179
|
+
subtype: 'step_complete',
|
|
180
|
+
content: result.response,
|
|
181
|
+
timestamp: new Date().toISOString(),
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// Collect artifacts
|
|
185
|
+
for (const artifact of result.artifacts) {
|
|
186
|
+
this.state.artifacts.push(artifact);
|
|
187
|
+
this.emitEvent({ type: 'artifact', agentId: this.config.id, artifact });
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Emit step summary (compact, human-friendly)
|
|
191
|
+
const stepSummary = summarizeStepResult(step.label, result.response, result.artifacts);
|
|
192
|
+
this.emitEvent({
|
|
193
|
+
type: 'log', agentId: this.config.id,
|
|
194
|
+
entry: { type: 'system', subtype: 'step_summary', content: stepSummary, timestamp: new Date().toISOString() },
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// Collect step result for memory update
|
|
198
|
+
this.stepResults.push(result.response);
|
|
199
|
+
|
|
200
|
+
// Checkpoint: this step succeeded
|
|
201
|
+
this.state.lastCheckpoint = i;
|
|
202
|
+
|
|
203
|
+
} catch (err: any) {
|
|
204
|
+
const msg = err?.message || String(err);
|
|
205
|
+
// Aborted = graceful stop (SIGTERM/SIGINT), not an error
|
|
206
|
+
if (msg === 'Aborted' || this.abortController?.signal.aborted) {
|
|
207
|
+
console.log(`[worker] ${this.config.label}: step catch — msg="${msg}", aborted=${this.abortController?.signal.aborted}`);
|
|
208
|
+
this.markMessageFailed();
|
|
209
|
+
this.setTaskStatus('failed', 'Interrupted');
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
this.markMessageFailed();
|
|
213
|
+
this.setTaskStatus('failed', msg);
|
|
214
|
+
this.emitEvent({ type: 'error', agentId: this.config.id, error: this.state.error! });
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// All steps done
|
|
220
|
+
this.markMessageDone(); // mark trigger message before emitting done
|
|
221
|
+
this.setTaskStatus('done');
|
|
222
|
+
this.state.completedAt = Date.now();
|
|
223
|
+
|
|
224
|
+
// Trigger memory update (orchestrator handles the actual LLM call)
|
|
225
|
+
if (this.onMemoryUpdate && this.stepResults.length > 0) {
|
|
226
|
+
try { this.onMemoryUpdate(this.stepResults); } catch {}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Emit final summary
|
|
230
|
+
const finalSummary = buildFinalSummary(this.config.label, this.config.steps, this.stepResults, this.state.artifacts);
|
|
231
|
+
this.emitEvent({
|
|
232
|
+
type: 'log', agentId: this.config.id,
|
|
233
|
+
entry: { type: 'result', subtype: 'final_summary', content: finalSummary, timestamp: new Date().toISOString() },
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
const summary = this.state.artifacts.length > 0
|
|
237
|
+
? `Completed. Artifacts: ${this.state.artifacts.map(a => a.path || a.summary).join(', ')}`
|
|
238
|
+
: 'Completed.';
|
|
239
|
+
this.emitEvent({ type: 'done', agentId: this.config.id, summary });
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Stop execution (abort current step and daemon loop) */
|
|
243
|
+
stop(): void {
|
|
244
|
+
console.log(`[worker] stop() called for ${this.config.label} (task=${this.state.taskStatus}, smith=${this.state.smithStatus})`, new Error().stack?.split('\n').slice(1, 4).join(' <- '));
|
|
245
|
+
this.abortController?.abort();
|
|
246
|
+
this.backend.abort();
|
|
247
|
+
this.setSmithStatus('down');
|
|
248
|
+
// Don't change taskStatus — keep done/failed/idle as-is
|
|
249
|
+
// Only change running → done (graceful stop of an in-progress task)
|
|
250
|
+
if (this.state.taskStatus === 'running') {
|
|
251
|
+
this.setTaskStatus('failed', 'Interrupted');
|
|
252
|
+
}
|
|
253
|
+
// If paused, release the pause wait
|
|
254
|
+
if (this.pauseResolve) {
|
|
255
|
+
this.pauseResolve();
|
|
256
|
+
this.pauseResolve = null;
|
|
257
|
+
}
|
|
258
|
+
// If in daemon wait, wake with abort
|
|
259
|
+
if (this.wakeResolve) {
|
|
260
|
+
this.wakeResolve({ type: 'abort' });
|
|
261
|
+
this.wakeResolve = null;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Pause after current step completes */
|
|
266
|
+
pause(): void {
|
|
267
|
+
if (this.state.taskStatus !== 'running') return;
|
|
268
|
+
this.paused = true;
|
|
269
|
+
// Paused is a sub-state of running, no separate taskStatus
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Resume from paused state */
|
|
273
|
+
resume(): void {
|
|
274
|
+
if (!this.paused) return;
|
|
275
|
+
this.paused = false;
|
|
276
|
+
if (this.pauseResolve) {
|
|
277
|
+
this.pauseResolve();
|
|
278
|
+
this.pauseResolve = null;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Inject a message from the bus (or human) into the pending queue.
|
|
284
|
+
* Will be consumed at the start of the next step.
|
|
285
|
+
*/
|
|
286
|
+
injectMessage(entry: TaskLogEntry): void {
|
|
287
|
+
this.pendingMessages.push(entry);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ─── Daemon Mode ──────────────────────────────────────
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Execute steps then enter daemon loop — agent stays alive waiting for events.
|
|
294
|
+
* Errors do NOT kill the daemon; agent retries with backoff.
|
|
295
|
+
*/
|
|
296
|
+
async executeDaemon(startStep = 0, upstreamContext?: string, skipSteps = false): Promise<void> {
|
|
297
|
+
if (!skipSteps) {
|
|
298
|
+
// Run initial steps
|
|
299
|
+
await this.execute(startStep, upstreamContext);
|
|
300
|
+
|
|
301
|
+
// If aborted, don't enter daemon loop
|
|
302
|
+
if (this.abortController?.signal.aborted) return;
|
|
303
|
+
} else {
|
|
304
|
+
// Skip steps — just prepare for daemon loop
|
|
305
|
+
this.abortController = new AbortController();
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Enter daemon mode: smith = active, task keeps its status (done/failed/idle)
|
|
309
|
+
this.state.daemonIteration = 0;
|
|
310
|
+
this.daemonRetryCount = 0;
|
|
311
|
+
this.setSmithStatus('active');
|
|
312
|
+
|
|
313
|
+
this.emitEvent({
|
|
314
|
+
type: 'log', agentId: this.config.id,
|
|
315
|
+
entry: { type: 'system', subtype: 'daemon', content: `[Smith] Active — listening for messages`, timestamp: new Date().toISOString() },
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
// Daemon loop — wait for messages, execute, repeat
|
|
319
|
+
while (!this.abortController?.signal.aborted) {
|
|
320
|
+
const reason = await this.waitForWake();
|
|
321
|
+
|
|
322
|
+
if (reason.type === 'abort') break;
|
|
323
|
+
|
|
324
|
+
this.state.daemonIteration = (this.state.daemonIteration || 0) + 1;
|
|
325
|
+
this.daemonRetryCount = 0;
|
|
326
|
+
|
|
327
|
+
try {
|
|
328
|
+
await this.executeDaemonStep(reason);
|
|
329
|
+
this.setTaskStatus('done');
|
|
330
|
+
// Emit done BEFORE markMessageDone — handleAgentDone needs getCurrentMessageId for causedBy
|
|
331
|
+
this.emitEvent({ type: 'done', agentId: this.config.id, summary: `Daemon iteration ${this.state.daemonIteration}` });
|
|
332
|
+
this.markMessageDone();
|
|
333
|
+
} catch (err: any) {
|
|
334
|
+
const msg = err?.message || String(err);
|
|
335
|
+
|
|
336
|
+
// Aborted = graceful stop, exit daemon loop
|
|
337
|
+
if (msg === 'Aborted' || this.abortController?.signal.aborted) break;
|
|
338
|
+
|
|
339
|
+
// Real errors: mark message failed, then backoff
|
|
340
|
+
this.markMessageFailed();
|
|
341
|
+
this.setTaskStatus('failed', msg);
|
|
342
|
+
this.emitEvent({ type: 'error', agentId: this.config.id, error: msg });
|
|
343
|
+
this.emitEvent({
|
|
344
|
+
type: 'log', agentId: this.config.id,
|
|
345
|
+
entry: { type: 'system', subtype: 'daemon', content: `[Smith] Error: ${msg}. Waiting for next event.`, timestamp: new Date().toISOString() },
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
const backoffMs = Math.min(5000 * Math.pow(2, this.daemonRetryCount++), 60_000);
|
|
349
|
+
await this.sleep(backoffMs);
|
|
350
|
+
|
|
351
|
+
if (this.abortController?.signal.aborted) break;
|
|
352
|
+
// Keep failed status — next wake event will set running again
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Exiting daemon loop
|
|
357
|
+
this.setSmithStatus('down');
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Wake the daemon from listening state */
|
|
361
|
+
wake(reason: DaemonWakeReason): void {
|
|
362
|
+
if (this.wakeResolve) {
|
|
363
|
+
this.wakeResolve(reason);
|
|
364
|
+
this.wakeResolve = null;
|
|
365
|
+
} else {
|
|
366
|
+
// Worker hasn't entered waitForWake yet — buffer the wake
|
|
367
|
+
this.pendingWake = reason;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Check if smith is active and idle (ready to receive messages) */
|
|
372
|
+
isListening(): boolean {
|
|
373
|
+
return this.state.smithStatus === 'active' && this.state.taskStatus !== 'running';
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Set the bus message ID being processed — smith marks it done/failed on completion */
|
|
377
|
+
setProcessingMessage(messageId: string): void {
|
|
378
|
+
this.currentMessageId = messageId;
|
|
379
|
+
this.state.currentMessageId = messageId;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Get the current message ID being processed */
|
|
383
|
+
getCurrentMessageId(): string | null {
|
|
384
|
+
return this.currentMessageId;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Mark current message as done — keep messageId for causedBy tracing */
|
|
388
|
+
private markMessageDone(): void {
|
|
389
|
+
if (this.currentMessageId && this.onMessageDone) {
|
|
390
|
+
this.onMessageDone(this.currentMessageId);
|
|
391
|
+
}
|
|
392
|
+
// Don't clear currentMessageId — it's needed by handleAgentDone for causedBy.
|
|
393
|
+
// It gets overwritten when next message is set via setProcessingMessage().
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** Mark current message as failed — keep messageId for error tracing */
|
|
397
|
+
private markMessageFailed(): void {
|
|
398
|
+
if (this.currentMessageId && this.onMessageFailed) {
|
|
399
|
+
this.onMessageFailed(this.currentMessageId);
|
|
400
|
+
}
|
|
401
|
+
// Don't clear — same reason as markMessageDone.
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
private waitForWake(): Promise<DaemonWakeReason> {
|
|
405
|
+
// Check if a wake was buffered while we were busy
|
|
406
|
+
if (this.pendingWake) {
|
|
407
|
+
const reason = this.pendingWake;
|
|
408
|
+
this.pendingWake = null;
|
|
409
|
+
return Promise.resolve(reason);
|
|
410
|
+
}
|
|
411
|
+
return new Promise<DaemonWakeReason>((resolve) => {
|
|
412
|
+
this.wakeResolve = resolve;
|
|
413
|
+
// Also resolve on abort
|
|
414
|
+
const onAbort = () => resolve({ type: 'abort' });
|
|
415
|
+
this.abortController?.signal.addEventListener('abort', onAbort, { once: true });
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
private async executeDaemonStep(reason: DaemonWakeReason): Promise<void> {
|
|
420
|
+
if (reason.type === 'abort') return;
|
|
421
|
+
|
|
422
|
+
this.setTaskStatus('running');
|
|
423
|
+
|
|
424
|
+
// Build prompt based on wake reason
|
|
425
|
+
let prompt: string;
|
|
426
|
+
switch (reason.type) {
|
|
427
|
+
case 'bus_message':
|
|
428
|
+
prompt = `You received new messages from other agents:\n${reason.messages.map(m => m.content).join('\n')}\n\nReact accordingly — update your work, respond, or take action as needed.`;
|
|
429
|
+
break;
|
|
430
|
+
case 'upstream_changed':
|
|
431
|
+
prompt = `Your upstream dependency (agent ${reason.agentId}) has produced new output: ${reason.files.join(', ')}.\n\nRe-analyze and update your work based on the new upstream output.`;
|
|
432
|
+
break;
|
|
433
|
+
case 'input_changed':
|
|
434
|
+
prompt = `New requirements have been provided:\n${reason.content}\n\nUpdate your work based on these new requirements.`;
|
|
435
|
+
break;
|
|
436
|
+
case 'user_message':
|
|
437
|
+
prompt = `User message: ${reason.content}\n\nRespond and take action as needed.`;
|
|
438
|
+
break;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Consume any pending bus messages
|
|
442
|
+
const contextMessages = [...this.pendingMessages];
|
|
443
|
+
this.pendingMessages = [];
|
|
444
|
+
|
|
445
|
+
for (const msg of contextMessages) {
|
|
446
|
+
this.state.history.push(msg);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Execute using the last step definition as template (or first if no steps)
|
|
450
|
+
const stepTemplate = this.config.steps[this.config.steps.length - 1] || this.config.steps[0];
|
|
451
|
+
if (!stepTemplate) {
|
|
452
|
+
// No steps defined — just log
|
|
453
|
+
this.state.history.push({
|
|
454
|
+
type: 'system', subtype: 'daemon',
|
|
455
|
+
content: `[Daemon] Wake: ${reason.type} — no steps defined to execute`,
|
|
456
|
+
timestamp: new Date().toISOString(),
|
|
457
|
+
});
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const step = {
|
|
462
|
+
...stepTemplate,
|
|
463
|
+
id: `daemon-${this.state.daemonIteration}`,
|
|
464
|
+
label: `Daemon iteration ${this.state.daemonIteration}`,
|
|
465
|
+
prompt,
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
this.emitEvent({ type: 'step', agentId: this.config.id, stepIndex: -1, stepLabel: step.label });
|
|
469
|
+
|
|
470
|
+
const result = await this.backend.executeStep({
|
|
471
|
+
config: this.config,
|
|
472
|
+
step,
|
|
473
|
+
stepIndex: -1,
|
|
474
|
+
history: this.state.history,
|
|
475
|
+
projectPath: this.projectPath,
|
|
476
|
+
workspaceId: this.workspaceId,
|
|
477
|
+
onBusSend: this.busCallbacks.onBusSend,
|
|
478
|
+
onBusRequest: this.busCallbacks.onBusRequest,
|
|
479
|
+
peerAgentIds: this.busCallbacks.peerAgentIds,
|
|
480
|
+
abortSignal: this.abortController?.signal,
|
|
481
|
+
onLog: (entry) => {
|
|
482
|
+
this.state.history.push(entry);
|
|
483
|
+
this.emitEvent({ type: 'log', agentId: this.config.id, entry });
|
|
484
|
+
},
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
// Validate result
|
|
488
|
+
const failureCheck = detectStepFailure(result.response);
|
|
489
|
+
if (failureCheck) {
|
|
490
|
+
throw new Error(failureCheck);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Record result
|
|
494
|
+
this.state.history.push({
|
|
495
|
+
type: 'result', subtype: 'daemon_step',
|
|
496
|
+
content: result.response,
|
|
497
|
+
timestamp: new Date().toISOString(),
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
// Collect artifacts
|
|
501
|
+
for (const artifact of result.artifacts) {
|
|
502
|
+
this.state.artifacts.push(artifact);
|
|
503
|
+
this.emitEvent({ type: 'artifact', agentId: this.config.id, artifact });
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const stepSummary = summarizeStepResult(step.label, result.response, result.artifacts);
|
|
507
|
+
this.emitEvent({
|
|
508
|
+
type: 'log', agentId: this.config.id,
|
|
509
|
+
entry: { type: 'system', subtype: 'step_summary', content: stepSummary, timestamp: new Date().toISOString() },
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
private sleep(ms: number): Promise<void> {
|
|
514
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** Get current state snapshot (immutable copy) */
|
|
518
|
+
getState(): Readonly<AgentState> {
|
|
519
|
+
return { ...this.state, currentMessageId: this.currentMessageId || this.state.currentMessageId };
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** Get the config */
|
|
523
|
+
getConfig(): Readonly<WorkspaceAgentConfig> {
|
|
524
|
+
return this.config;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// ─── Private ─────────────────────────────────────────
|
|
528
|
+
|
|
529
|
+
private setTaskStatus(taskStatus: TaskStatus, error?: string): void {
|
|
530
|
+
this.state.taskStatus = taskStatus;
|
|
531
|
+
this.state.error = error;
|
|
532
|
+
this.emitEvent({ type: 'task_status', agentId: this.config.id, taskStatus, error });
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
private setSmithStatus(smithStatus: SmithStatus, mode?: AgentMode): void {
|
|
536
|
+
this.state.smithStatus = smithStatus;
|
|
537
|
+
if (mode) this.state.mode = mode;
|
|
538
|
+
this.emitEvent({ type: 'smith_status', agentId: this.config.id, smithStatus, mode: this.state.mode });
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
private emitEvent(event: WorkerEvent): void {
|
|
542
|
+
this.emit('event', event);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
private waitIfPaused(): Promise<void> {
|
|
546
|
+
if (!this.paused) return Promise.resolve();
|
|
547
|
+
return new Promise<void>(resolve => {
|
|
548
|
+
this.pauseResolve = resolve;
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// ─── Summary helpers (no LLM, pure heuristic) ────────────
|
|
554
|
+
|
|
555
|
+
/** Extract a compact step summary from raw output */
|
|
556
|
+
/**
|
|
557
|
+
* Detect if a step's result indicates failure — covers all agent types:
|
|
558
|
+
* - Rate/usage limits (codex, claude, API)
|
|
559
|
+
* - Auth failures
|
|
560
|
+
* - Subscription limits (claude code)
|
|
561
|
+
* - Empty/meaningless output
|
|
562
|
+
* Returns error message if failure detected, null if OK.
|
|
563
|
+
*/
|
|
564
|
+
function detectStepFailure(response: string): string | null {
|
|
565
|
+
if (!response || response.trim().length === 0) {
|
|
566
|
+
return 'Agent produced no output — step may not have executed';
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const patterns: [RegExp, string][] = [
|
|
570
|
+
// Usage/rate limits
|
|
571
|
+
[/usage limit/i, 'Usage limit reached'],
|
|
572
|
+
[/rate limit/i, 'Rate limit reached'],
|
|
573
|
+
[/hit your.*limit/i, 'Account limit reached'],
|
|
574
|
+
[/upgrade to (plus|pro|max)/i, 'Subscription upgrade required'],
|
|
575
|
+
[/try again (at|in|after)/i, 'Temporarily unavailable — try again later'],
|
|
576
|
+
// Claude Code specific
|
|
577
|
+
[/exceeded.*monthly.*limit/i, 'Monthly usage limit exceeded'],
|
|
578
|
+
[/opus limit|sonnet limit/i, 'Model usage limit reached'],
|
|
579
|
+
[/you've been rate limited/i, 'Rate limited'],
|
|
580
|
+
// API errors
|
|
581
|
+
[/api key.*invalid/i, 'Invalid API key'],
|
|
582
|
+
[/authentication failed/i, 'Authentication failed'],
|
|
583
|
+
[/insufficient.*quota/i, 'Insufficient API quota'],
|
|
584
|
+
[/billing.*not.*active/i, 'Billing not active'],
|
|
585
|
+
[/overloaded|server error|503|502/i, 'Service temporarily unavailable'],
|
|
586
|
+
];
|
|
587
|
+
|
|
588
|
+
for (const [pattern, msg] of patterns) {
|
|
589
|
+
if (pattern.test(response)) {
|
|
590
|
+
// Extract the actual error line for context
|
|
591
|
+
const errorLine = response.split('\n').find(l => pattern.test(l))?.trim();
|
|
592
|
+
return `${msg}${errorLine ? ': ' + errorLine.slice(0, 150) : ''}`;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// Check for very short output that's just noise (spinner artifacts, etc.)
|
|
597
|
+
const meaningful = response.replace(/[^a-zA-Z0-9\u4e00-\u9fff]/g, '').trim();
|
|
598
|
+
if (meaningful.length < 10 && response.length > 50) {
|
|
599
|
+
return 'Agent output appears to be only terminal noise — execution may have failed';
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
return null;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function summarizeStepResult(stepLabel: string, rawResult: string, artifacts: { path?: string; summary?: string }[]): string {
|
|
606
|
+
const lines: string[] = [];
|
|
607
|
+
lines.push(`✅ Step "${stepLabel}" done`);
|
|
608
|
+
|
|
609
|
+
// Extract key sentences (first meaningful line, skip noise)
|
|
610
|
+
const meaningful = rawResult
|
|
611
|
+
.split('\n')
|
|
612
|
+
.map(l => l.trim())
|
|
613
|
+
.filter(l => l.length > 15 && l.length < 300)
|
|
614
|
+
.filter(l => !/^[#\-*>|`]/.test(l)) // skip markdown headers, bullets, code blocks
|
|
615
|
+
.filter(l => !/^(Working|W$|Wo$|•)/.test(l)); // skip codex noise
|
|
616
|
+
|
|
617
|
+
if (meaningful.length > 0) {
|
|
618
|
+
lines.push(` ${meaningful[0].slice(0, 120)}`);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// List artifacts
|
|
622
|
+
const filePaths = artifacts.filter(a => a.path).map(a => a.path!);
|
|
623
|
+
if (filePaths.length > 0) {
|
|
624
|
+
lines.push(` Files: ${filePaths.join(', ')}`);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
return lines.join('\n');
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/** Build a final summary after all steps complete */
|
|
631
|
+
function buildFinalSummary(
|
|
632
|
+
agentLabel: string,
|
|
633
|
+
steps: { label: string }[],
|
|
634
|
+
stepResults: string[],
|
|
635
|
+
artifacts: { path?: string; summary?: string }[],
|
|
636
|
+
): string {
|
|
637
|
+
const lines: string[] = [];
|
|
638
|
+
lines.push(`══════════════════════════════════════`);
|
|
639
|
+
lines.push(`📊 ${agentLabel} — Summary`);
|
|
640
|
+
lines.push(`──────────────────────────────────────`);
|
|
641
|
+
|
|
642
|
+
// Steps completed
|
|
643
|
+
lines.push(`Steps: ${steps.map(s => s.label).join(' → ')}`);
|
|
644
|
+
|
|
645
|
+
// Key output per step (one line each)
|
|
646
|
+
for (let i = 0; i < steps.length; i++) {
|
|
647
|
+
const result = stepResults[i] || '';
|
|
648
|
+
const firstLine = result
|
|
649
|
+
.split('\n')
|
|
650
|
+
.map(l => l.trim())
|
|
651
|
+
.filter(l => l.length > 15 && l.length < 200)
|
|
652
|
+
.filter(l => !/^[#\-*>|`]/.test(l))
|
|
653
|
+
.filter(l => !/^(Working|W$|Wo$|•)/.test(l))[0];
|
|
654
|
+
if (firstLine) {
|
|
655
|
+
lines.push(` ${steps[i].label}: ${firstLine.slice(0, 100)}`);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// All artifacts
|
|
660
|
+
const files = artifacts.filter(a => a.path).map(a => a.path!);
|
|
661
|
+
if (files.length > 0) {
|
|
662
|
+
lines.push(`Produced: ${files.join(', ')}`);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
lines.push(`══════════════════════════════════════`);
|
|
666
|
+
return lines.join('\n');
|
|
667
|
+
}
|