@aion0/forge 0.4.16 → 0.5.0
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 +1 -1
- package/RELEASE_NOTES.md +170 -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 +2224 -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 +204 -0
- package/lib/help-docs/CLAUDE.md +5 -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 +1804 -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 +790 -0
- package/middleware.ts +1 -0
- 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,1804 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WorkspaceOrchestrator — manages a group of agents within a workspace.
|
|
3
|
+
*
|
|
4
|
+
* Responsibilities:
|
|
5
|
+
* - Create/remove agents
|
|
6
|
+
* - Run agents (auto-select backend, inject upstream context)
|
|
7
|
+
* - Listen to agent events → trigger downstream agents
|
|
8
|
+
* - Approval gating
|
|
9
|
+
* - Parallel execution (independent agents run concurrently)
|
|
10
|
+
* - Error recovery (restart from lastCheckpoint)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { EventEmitter } from 'node:events';
|
|
14
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
15
|
+
import { resolve } from 'node:path';
|
|
16
|
+
import type {
|
|
17
|
+
WorkspaceAgentConfig,
|
|
18
|
+
AgentState,
|
|
19
|
+
SmithStatus,
|
|
20
|
+
TaskStatus,
|
|
21
|
+
AgentMode,
|
|
22
|
+
WorkerEvent,
|
|
23
|
+
BusMessage,
|
|
24
|
+
Artifact,
|
|
25
|
+
WorkspaceState,
|
|
26
|
+
DaemonWakeReason,
|
|
27
|
+
} from './types';
|
|
28
|
+
import { AgentWorker } from './agent-worker';
|
|
29
|
+
import { AgentBus } from './agent-bus';
|
|
30
|
+
import { WatchManager } from './watch-manager';
|
|
31
|
+
import { ApiBackend } from './backends/api-backend';
|
|
32
|
+
import { CliBackend } from './backends/cli-backend';
|
|
33
|
+
import { appendAgentLog, saveWorkspace, saveWorkspaceSync, startAutoSave, stopAutoSave } from './persistence';
|
|
34
|
+
import { hasForgeSkills, installForgeSkills } from './skill-installer';
|
|
35
|
+
import {
|
|
36
|
+
loadMemory, saveMemory, createMemory, formatMemoryForPrompt,
|
|
37
|
+
addObservation, addSessionSummary, parseStepToObservations, buildSessionSummary,
|
|
38
|
+
} from './smith-memory';
|
|
39
|
+
|
|
40
|
+
// ─── Orchestrator Events ─────────────────────────────────
|
|
41
|
+
|
|
42
|
+
export type OrchestratorEvent =
|
|
43
|
+
| WorkerEvent
|
|
44
|
+
| { type: 'bus_message'; message: BusMessage }
|
|
45
|
+
| { type: 'approval_required'; agentId: string; upstreamId: string }
|
|
46
|
+
| { type: 'user_input_request'; agentId: string; fromAgent: string; question: string }
|
|
47
|
+
| { type: 'workspace_status'; running: number; done: number; total: number }
|
|
48
|
+
| { type: 'workspace_complete' }
|
|
49
|
+
| { type: 'watch_alert'; agentId: string; changes: any[]; summary: string; timestamp: number };
|
|
50
|
+
|
|
51
|
+
// ─── Orchestrator class ──────────────────────────────────
|
|
52
|
+
|
|
53
|
+
export class WorkspaceOrchestrator extends EventEmitter {
|
|
54
|
+
readonly workspaceId: string;
|
|
55
|
+
readonly projectPath: string;
|
|
56
|
+
readonly projectName: string;
|
|
57
|
+
|
|
58
|
+
private agents = new Map<string, { config: WorkspaceAgentConfig; worker: AgentWorker | null; state: AgentState }>();
|
|
59
|
+
private bus: AgentBus;
|
|
60
|
+
private watchManager: WatchManager;
|
|
61
|
+
private approvalQueue = new Set<string>();
|
|
62
|
+
private daemonActive = false;
|
|
63
|
+
private createdAt = Date.now();
|
|
64
|
+
|
|
65
|
+
constructor(workspaceId: string, projectPath: string, projectName: string) {
|
|
66
|
+
super();
|
|
67
|
+
this.workspaceId = workspaceId;
|
|
68
|
+
this.projectPath = projectPath;
|
|
69
|
+
this.projectName = projectName;
|
|
70
|
+
this.bus = new AgentBus();
|
|
71
|
+
this.watchManager = new WatchManager(workspaceId, projectPath, () => this.agents as any);
|
|
72
|
+
// Handle watch events
|
|
73
|
+
this.watchManager.on('watch_alert', (event) => {
|
|
74
|
+
this.emit('event', event);
|
|
75
|
+
// Push alert to agent history so Log panel shows it
|
|
76
|
+
const alertEntry = this.agents.get(event.agentId);
|
|
77
|
+
if (alertEntry && event.entry) {
|
|
78
|
+
alertEntry.state.history.push(event.entry);
|
|
79
|
+
this.emit('event', { type: 'log', agentId: event.agentId, entry: event.entry } as any);
|
|
80
|
+
}
|
|
81
|
+
this.handleWatchAlert(event.agentId, event.summary);
|
|
82
|
+
});
|
|
83
|
+
// Note: watch_heartbeat (no changes) only logs to console, not to agent history/logs.jsonl
|
|
84
|
+
|
|
85
|
+
// Forward bus messages as orchestrator events (after dedup, skip ACKs)
|
|
86
|
+
this.bus.on('message', (msg: BusMessage) => {
|
|
87
|
+
if (msg.type === 'ack') return; // ACKs are internal, don't emit to UI
|
|
88
|
+
if (msg.to === '_system') {
|
|
89
|
+
this.emit('event', { type: 'bus_message', message: msg } satisfies OrchestratorEvent);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
this.handleBusMessage(msg);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Start auto-save (every 10 seconds)
|
|
96
|
+
startAutoSave(workspaceId, () => this.getFullState());
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ─── Agent Management ──────────────────────────────────
|
|
100
|
+
|
|
101
|
+
/** Check if agent outputs or workDir conflict with existing agents */
|
|
102
|
+
private validateOutputs(config: WorkspaceAgentConfig, excludeId?: string): string | null {
|
|
103
|
+
if (config.type === 'input') return null;
|
|
104
|
+
|
|
105
|
+
const normalize = (p: string) => p.replace(/^\.?\//, '').replace(/\/$/, '') || '.';
|
|
106
|
+
|
|
107
|
+
// Validate workDir is within project (no ../ escape)
|
|
108
|
+
if (config.workDir) {
|
|
109
|
+
const relativeDir = config.workDir.replace(/^\.?\//, '');
|
|
110
|
+
if (relativeDir.includes('..')) {
|
|
111
|
+
return `Work directory "${config.workDir}" contains "..". Must be a subdirectory of the project.`;
|
|
112
|
+
}
|
|
113
|
+
const projectRoot = this.projectPath.endsWith('/') ? this.projectPath : this.projectPath + '/';
|
|
114
|
+
const resolved = resolve(this.projectPath, relativeDir);
|
|
115
|
+
if (resolved !== this.projectPath && !resolved.startsWith(projectRoot)) {
|
|
116
|
+
return `Work directory "${config.workDir}" is outside the project. Must be a subdirectory.`;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Every non-input smith must have a unique workDir
|
|
121
|
+
const newDir = normalize(config.workDir || '.');
|
|
122
|
+
|
|
123
|
+
for (const [id, entry] of this.agents) {
|
|
124
|
+
if (id === excludeId || entry.config.type === 'input') continue;
|
|
125
|
+
|
|
126
|
+
const existingDir = normalize(entry.config.workDir || '.');
|
|
127
|
+
|
|
128
|
+
// Same workDir → conflict
|
|
129
|
+
if (newDir === existingDir) {
|
|
130
|
+
return `Work directory conflict: "${config.label}" and "${entry.config.label}" both use "${newDir === '.' ? 'project root' : newDir}/". Each smith must have a unique work directory.`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// One is parent of the other → conflict (e.g., "src" and "src/components")
|
|
134
|
+
if (newDir.startsWith(existingDir + '/') || existingDir.startsWith(newDir + '/')) {
|
|
135
|
+
return `Work directory conflict: "${config.label}" (${newDir}/) overlaps with "${entry.config.label}" (${existingDir}/). Nested directories not allowed.`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Check output path overlap
|
|
139
|
+
for (const out of config.outputs) {
|
|
140
|
+
for (const existing of entry.config.outputs) {
|
|
141
|
+
if (normalize(out) === normalize(existing)) {
|
|
142
|
+
return `Output conflict: "${config.label}" and "${entry.config.label}" both output to "${out}"`;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Detect if adding dependsOn edges would create a cycle in the DAG */
|
|
151
|
+
private detectCycle(agentId: string, dependsOn: string[]): string | null {
|
|
152
|
+
// Build adjacency: agent → agents it depends on
|
|
153
|
+
const deps = new Map<string, string[]>();
|
|
154
|
+
for (const [id, entry] of this.agents) {
|
|
155
|
+
if (id !== agentId) deps.set(id, [...entry.config.dependsOn]);
|
|
156
|
+
}
|
|
157
|
+
deps.set(agentId, [...dependsOn]);
|
|
158
|
+
|
|
159
|
+
// DFS cycle detection
|
|
160
|
+
const visited = new Set<string>();
|
|
161
|
+
const inStack = new Set<string>();
|
|
162
|
+
|
|
163
|
+
const dfs = (node: string): string | null => {
|
|
164
|
+
if (inStack.has(node)) return node; // cycle found
|
|
165
|
+
if (visited.has(node)) return null;
|
|
166
|
+
visited.add(node);
|
|
167
|
+
inStack.add(node);
|
|
168
|
+
for (const dep of deps.get(node) || []) {
|
|
169
|
+
const cycle = dfs(dep);
|
|
170
|
+
if (cycle) return cycle;
|
|
171
|
+
}
|
|
172
|
+
inStack.delete(node);
|
|
173
|
+
return null;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
for (const id of deps.keys()) {
|
|
177
|
+
const cycle = dfs(id);
|
|
178
|
+
if (cycle) {
|
|
179
|
+
const cycleName = this.agents.get(cycle)?.config.label || cycle;
|
|
180
|
+
return `Circular dependency detected involving "${cycleName}". Dependencies must form a DAG (no cycles).`;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Check if agentA is upstream of agentB (A is in B's dependency chain) */
|
|
187
|
+
isUpstream(agentA: string, agentB: string): boolean {
|
|
188
|
+
const visited = new Set<string>();
|
|
189
|
+
const check = (current: string): boolean => {
|
|
190
|
+
if (current === agentA) return true;
|
|
191
|
+
if (visited.has(current)) return false;
|
|
192
|
+
visited.add(current);
|
|
193
|
+
const entry = this.agents.get(current);
|
|
194
|
+
if (!entry) return false;
|
|
195
|
+
return entry.config.dependsOn.some(dep => check(dep));
|
|
196
|
+
};
|
|
197
|
+
return check(agentB);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
addAgent(config: WorkspaceAgentConfig): void {
|
|
201
|
+
const conflict = this.validateOutputs(config);
|
|
202
|
+
if (conflict) throw new Error(conflict);
|
|
203
|
+
|
|
204
|
+
// Check DAG cycle before adding
|
|
205
|
+
const cycleErr = this.detectCycle(config.id, config.dependsOn);
|
|
206
|
+
if (cycleErr) throw new Error(cycleErr);
|
|
207
|
+
|
|
208
|
+
const state: AgentState = {
|
|
209
|
+
smithStatus: 'down',
|
|
210
|
+
mode: 'auto',
|
|
211
|
+
taskStatus: 'idle',
|
|
212
|
+
history: [],
|
|
213
|
+
artifacts: [],
|
|
214
|
+
};
|
|
215
|
+
this.agents.set(config.id, { config, worker: null, state });
|
|
216
|
+
this.saveNow();
|
|
217
|
+
this.emitAgentsChanged();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
removeAgent(id: string): void {
|
|
221
|
+
const entry = this.agents.get(id);
|
|
222
|
+
if (entry?.worker) {
|
|
223
|
+
entry.worker.stop();
|
|
224
|
+
}
|
|
225
|
+
this.agents.delete(id);
|
|
226
|
+
this.approvalQueue.delete(id);
|
|
227
|
+
|
|
228
|
+
// Clean up dangling dependsOn references in other agents
|
|
229
|
+
for (const [, other] of this.agents) {
|
|
230
|
+
const idx = other.config.dependsOn.indexOf(id);
|
|
231
|
+
if (idx !== -1) {
|
|
232
|
+
other.config.dependsOn.splice(idx, 1);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
this.saveNow();
|
|
237
|
+
this.emitAgentsChanged();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
updateAgentConfig(id: string, config: WorkspaceAgentConfig): void {
|
|
241
|
+
const entry = this.agents.get(id);
|
|
242
|
+
if (!entry) return;
|
|
243
|
+
const conflict = this.validateOutputs(config, id);
|
|
244
|
+
if (conflict) throw new Error(conflict);
|
|
245
|
+
const cycleErr = this.detectCycle(id, config.dependsOn);
|
|
246
|
+
if (cycleErr) throw new Error(cycleErr);
|
|
247
|
+
if (entry.worker && entry.state.taskStatus === 'running') {
|
|
248
|
+
entry.worker.stop();
|
|
249
|
+
}
|
|
250
|
+
entry.config = config;
|
|
251
|
+
// Reset status but keep history/artifacts (don't wipe logs)
|
|
252
|
+
entry.state.taskStatus = 'idle';
|
|
253
|
+
entry.state.error = undefined;
|
|
254
|
+
entry.worker = null;
|
|
255
|
+
// Restart watch if config changed
|
|
256
|
+
if (this.daemonActive) {
|
|
257
|
+
this.watchManager.startWatch(id, config);
|
|
258
|
+
}
|
|
259
|
+
this.saveNow();
|
|
260
|
+
this.emitAgentsChanged();
|
|
261
|
+
// Push status update so frontend reflects the reset
|
|
262
|
+
this.emit('event', { type: 'task_status', agentId: id, taskStatus: 'idle' } satisfies WorkerEvent);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
getAgentState(id: string): Readonly<AgentState> | undefined {
|
|
266
|
+
return this.agents.get(id)?.state;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
getAllAgentStates(): Record<string, AgentState> {
|
|
270
|
+
const result: Record<string, AgentState> = {};
|
|
271
|
+
for (const [id, entry] of this.agents) {
|
|
272
|
+
const workerState = entry.worker?.getState();
|
|
273
|
+
// Merge: worker state for task/smith, entry.state for mode (orchestrator controls mode)
|
|
274
|
+
result[id] = workerState
|
|
275
|
+
? { ...workerState, mode: entry.state.mode }
|
|
276
|
+
: entry.state;
|
|
277
|
+
}
|
|
278
|
+
return result;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ─── Execution ─────────────────────────────────────────
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Complete an Input node — set its content and mark as done.
|
|
285
|
+
* If re-submitted, resets downstream agents so they can re-run.
|
|
286
|
+
*/
|
|
287
|
+
completeInput(agentId: string, content: string): void {
|
|
288
|
+
const entry = this.agents.get(agentId);
|
|
289
|
+
if (!entry || entry.config.type !== 'input') return;
|
|
290
|
+
|
|
291
|
+
const isUpdate = entry.state.taskStatus === 'done';
|
|
292
|
+
|
|
293
|
+
// Append to entries (incremental, not overwrite)
|
|
294
|
+
if (!entry.config.entries) entry.config.entries = [];
|
|
295
|
+
entry.config.entries.push({ content, timestamp: Date.now() });
|
|
296
|
+
// Keep bounded — max 100 entries, oldest removed
|
|
297
|
+
if (entry.config.entries.length > 100) {
|
|
298
|
+
entry.config.entries = entry.config.entries.slice(-100);
|
|
299
|
+
}
|
|
300
|
+
// Also set content to latest for backward compat
|
|
301
|
+
entry.config.content = content;
|
|
302
|
+
|
|
303
|
+
entry.state.taskStatus = 'done';
|
|
304
|
+
entry.state.completedAt = Date.now();
|
|
305
|
+
entry.state.artifacts = [{ type: 'text', summary: content.slice(0, 200) }];
|
|
306
|
+
|
|
307
|
+
this.emit('event', { type: 'task_status', agentId, taskStatus: 'done' } satisfies WorkerEvent);
|
|
308
|
+
this.emit('event', { type: 'done', agentId, summary: 'Input provided' } satisfies WorkerEvent);
|
|
309
|
+
this.emitAgentsChanged(); // push updated entries to frontend
|
|
310
|
+
this.bus.notifyTaskComplete(agentId, [], content.slice(0, 200));
|
|
311
|
+
|
|
312
|
+
// Send input_updated messages to downstream agents via bus
|
|
313
|
+
// routeMessageToAgent handles auto-execution for active smiths
|
|
314
|
+
for (const [id, downstream] of this.agents) {
|
|
315
|
+
if (downstream.config.type === 'input') continue;
|
|
316
|
+
if (!downstream.config.dependsOn.includes(agentId)) continue;
|
|
317
|
+
this.bus.send(agentId, id, 'notify', {
|
|
318
|
+
action: 'input_updated',
|
|
319
|
+
content: content.slice(0, 500),
|
|
320
|
+
});
|
|
321
|
+
console.log(`[bus] Input → ${downstream.config.label}: input_updated`);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
this.saveNow();
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Reset an agent and all its downstream to idle (for re-run) */
|
|
328
|
+
resetAgent(agentId: string): void {
|
|
329
|
+
const entry = this.agents.get(agentId);
|
|
330
|
+
if (!entry) return;
|
|
331
|
+
if (entry.worker) entry.worker.stop();
|
|
332
|
+
entry.worker = null;
|
|
333
|
+
// Kill orphaned tmux session if manual agent
|
|
334
|
+
if (entry.state.tmuxSession) {
|
|
335
|
+
try {
|
|
336
|
+
const { execSync } = require('node:child_process');
|
|
337
|
+
execSync(`tmux kill-session -t "${entry.state.tmuxSession}" 2>/dev/null`, { timeout: 3000 });
|
|
338
|
+
console.log(`[workspace] Killed tmux session ${entry.state.tmuxSession}`);
|
|
339
|
+
} catch {} // session might already be dead
|
|
340
|
+
}
|
|
341
|
+
entry.state = { smithStatus: 'down', mode: 'auto', taskStatus: 'idle', history: entry.state.history, artifacts: [] };
|
|
342
|
+
this.emit('event', { type: 'task_status', agentId, taskStatus: 'idle' } satisfies WorkerEvent);
|
|
343
|
+
this.emitAgentsChanged();
|
|
344
|
+
this.saveNow();
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Reset all agents that depend on the given agent (recursively) */
|
|
348
|
+
private resetDownstream(agentId: string, visited = new Set<string>()): void {
|
|
349
|
+
if (visited.has(agentId)) return; // cycle protection
|
|
350
|
+
visited.add(agentId);
|
|
351
|
+
|
|
352
|
+
for (const [id, entry] of this.agents) {
|
|
353
|
+
if (id === agentId) continue;
|
|
354
|
+
if (!entry.config.dependsOn.includes(agentId)) continue;
|
|
355
|
+
if (entry.state.taskStatus === 'idle') continue;
|
|
356
|
+
console.log(`[workspace] Resetting ${entry.config.label} (${id}) to idle (upstream ${agentId} changed)`);
|
|
357
|
+
if (entry.worker) entry.worker.stop();
|
|
358
|
+
entry.worker = null;
|
|
359
|
+
entry.state = { smithStatus: entry.state.smithStatus, mode: entry.state.mode, taskStatus: 'idle', history: entry.state.history, artifacts: [], cliSessionId: entry.state.cliSessionId };
|
|
360
|
+
this.emit('event', { type: 'task_status', agentId: id, taskStatus: 'idle' } satisfies WorkerEvent);
|
|
361
|
+
this.resetDownstream(id, visited);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Validate that an agent can run (sync check). Throws on error. */
|
|
366
|
+
validateCanRun(agentId: string): void {
|
|
367
|
+
const entry = this.agents.get(agentId);
|
|
368
|
+
if (!entry) throw new Error(`Agent "${agentId}" not found`);
|
|
369
|
+
if (entry.config.type === 'input') return;
|
|
370
|
+
if (entry.state.taskStatus === 'running') throw new Error(`Agent "${entry.config.label}" is already running`);
|
|
371
|
+
for (const depId of entry.config.dependsOn) {
|
|
372
|
+
const dep = this.agents.get(depId);
|
|
373
|
+
if (!dep) throw new Error(`Dependency "${depId}" not found (deleted?). Edit the agent to fix.`);
|
|
374
|
+
if (dep.state.taskStatus !== 'done') {
|
|
375
|
+
const hint = dep.state.taskStatus === 'idle' ? ' (never executed — run it first)'
|
|
376
|
+
: dep.state.taskStatus === 'failed' ? ' (failed — retry it first)'
|
|
377
|
+
: dep.state.taskStatus === 'running' ? ' (still running — wait for it to finish)'
|
|
378
|
+
: '';
|
|
379
|
+
throw new Error(`Dependency "${dep.config.label}" not completed yet${hint}`);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Run a specific agent. Requires daemon mode. force=true bypasses status checks (for retry). */
|
|
385
|
+
async runAgent(agentId: string, userInput?: string, force = false): Promise<void> {
|
|
386
|
+
if (!this.daemonActive) {
|
|
387
|
+
throw new Error('Start daemon first before running agents');
|
|
388
|
+
}
|
|
389
|
+
const label = this.agents.get(agentId)?.config.label || agentId;
|
|
390
|
+
console.log(`[workspace] runAgent(${label}, force=${force})`, new Error().stack?.split('\n').slice(2, 5).join(' <- '));
|
|
391
|
+
return this.runAgentDaemon(agentId, userInput, force);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** @deprecated Use runAgent (which now delegates to daemon mode) */
|
|
395
|
+
private async runAgentLegacy(agentId: string, userInput?: string): Promise<void> {
|
|
396
|
+
const entry = this.agents.get(agentId);
|
|
397
|
+
if (!entry) throw new Error(`Agent "${agentId}" not found`);
|
|
398
|
+
|
|
399
|
+
// Input nodes are completed via completeInput(), not run
|
|
400
|
+
if (entry.config.type === 'input') {
|
|
401
|
+
if (userInput) this.completeInput(agentId, userInput);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (entry.state.taskStatus === 'running') return;
|
|
406
|
+
|
|
407
|
+
// Allow re-running done/failed/idle(was interrupted)/waiting_approval agents — reset them first
|
|
408
|
+
let resumeFromCheckpoint = false;
|
|
409
|
+
if (entry.state.taskStatus === 'done' || entry.state.taskStatus === 'failed' || entry.state.taskStatus === 'idle' || this.approvalQueue.has(agentId)) {
|
|
410
|
+
this.approvalQueue.delete(agentId);
|
|
411
|
+
console.log(`[workspace] Re-running ${entry.config.label} (was taskStatus=${entry.state.taskStatus})`);
|
|
412
|
+
// For failed: keep lastCheckpoint for resume
|
|
413
|
+
resumeFromCheckpoint = (entry.state.taskStatus === 'failed')
|
|
414
|
+
&& entry.state.lastCheckpoint !== undefined;
|
|
415
|
+
if (entry.worker) entry.worker.stop();
|
|
416
|
+
entry.worker = null;
|
|
417
|
+
if (!resumeFromCheckpoint) {
|
|
418
|
+
entry.state = { smithStatus: entry.state.smithStatus, mode: entry.state.mode, taskStatus: 'idle', history: entry.state.history, artifacts: [], cliSessionId: entry.state.cliSessionId };
|
|
419
|
+
} else {
|
|
420
|
+
entry.state.taskStatus = 'idle';
|
|
421
|
+
entry.state.error = undefined;
|
|
422
|
+
entry.state.mode = 'auto';
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const { config } = entry;
|
|
427
|
+
|
|
428
|
+
// Check if all dependencies are done
|
|
429
|
+
for (const depId of config.dependsOn) {
|
|
430
|
+
const dep = this.agents.get(depId);
|
|
431
|
+
if (!dep || dep.state.taskStatus !== 'done') {
|
|
432
|
+
throw new Error(`Dependency "${dep?.config.label || depId}" not completed yet`);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Build upstream context from dependencies (includes Input node content)
|
|
437
|
+
let upstreamContext = this.buildUpstreamContext(config);
|
|
438
|
+
if (userInput) {
|
|
439
|
+
const prefix = '## Additional Instructions:\n' + userInput;
|
|
440
|
+
upstreamContext = upstreamContext ? prefix + '\n\n---\n\n' + upstreamContext : prefix;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Create backend
|
|
444
|
+
const backend = this.createBackend(config, agentId);
|
|
445
|
+
|
|
446
|
+
// Create worker with bus callbacks for inter-agent communication
|
|
447
|
+
// Load agent memory
|
|
448
|
+
const memory = loadMemory(this.workspaceId, agentId);
|
|
449
|
+
const memoryContext = formatMemoryForPrompt(memory);
|
|
450
|
+
|
|
451
|
+
const peerAgentIds = Array.from(this.agents.keys()).filter(id => id !== agentId);
|
|
452
|
+
const worker = new AgentWorker({
|
|
453
|
+
config,
|
|
454
|
+
backend,
|
|
455
|
+
projectPath: this.projectPath, workspaceId: this.workspaceId,
|
|
456
|
+
peerAgentIds,
|
|
457
|
+
memoryContext: memoryContext || undefined,
|
|
458
|
+
onBusSend: (to, content) => {
|
|
459
|
+
this.bus.send(agentId, to, 'notify', { action: 'agent_message', content });
|
|
460
|
+
},
|
|
461
|
+
onBusRequest: async (to, question) => {
|
|
462
|
+
const response = await this.bus.request(agentId, to, { action: 'question', content: question });
|
|
463
|
+
return response.payload.content || '(no response)';
|
|
464
|
+
},
|
|
465
|
+
onMemoryUpdate: (stepResults) => {
|
|
466
|
+
this.updateAgentMemory(agentId, config, stepResults);
|
|
467
|
+
},
|
|
468
|
+
});
|
|
469
|
+
entry.worker = worker;
|
|
470
|
+
|
|
471
|
+
// Forward worker events
|
|
472
|
+
worker.on('event', (event: WorkerEvent) => {
|
|
473
|
+
// Sync state
|
|
474
|
+
entry.state = worker.getState() as AgentState;
|
|
475
|
+
|
|
476
|
+
// Persist log entries to disk
|
|
477
|
+
if (event.type === 'log') {
|
|
478
|
+
appendAgentLog(this.workspaceId, agentId, event.entry).catch(() => {});
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
this.emit('event', event);
|
|
482
|
+
|
|
483
|
+
// Update liveness
|
|
484
|
+
if (event.type === 'task_status' || event.type === 'smith_status') {
|
|
485
|
+
this.updateAgentLiveness(agentId);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// On step complete → capture observation + notify bus
|
|
489
|
+
if (event.type === 'step') {
|
|
490
|
+
const step = config.steps[event.stepIndex];
|
|
491
|
+
if (step) {
|
|
492
|
+
this.bus.notifyStepComplete(agentId, step.label);
|
|
493
|
+
|
|
494
|
+
// Capture memory observation from the previous step's result
|
|
495
|
+
const prevStepIdx = event.stepIndex - 1;
|
|
496
|
+
if (prevStepIdx >= 0) {
|
|
497
|
+
const prevStep = config.steps[prevStepIdx];
|
|
498
|
+
const prevResult = entry.state.history
|
|
499
|
+
.filter(h => h.type === 'result' && h.subtype === 'step_complete')
|
|
500
|
+
.slice(-1)[0];
|
|
501
|
+
if (prevResult && prevStep) {
|
|
502
|
+
const obs = parseStepToObservations(prevStep.label, prevResult.content, entry.state.artifacts);
|
|
503
|
+
for (const o of obs) {
|
|
504
|
+
addObservation(this.workspaceId, agentId, config.label, config.role, o).catch(() => {});
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// On done → notify + trigger downstream (or reply to sender if from downstream)
|
|
512
|
+
if (event.type === 'done') {
|
|
513
|
+
this.handleAgentDone(agentId, entry, event.summary);
|
|
514
|
+
|
|
515
|
+
this.emitWorkspaceStatus();
|
|
516
|
+
this.checkWorkspaceComplete();
|
|
517
|
+
|
|
518
|
+
// Note: no auto-rerun. Bus messages that need re-run go through user approval.
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// On error → notify bus
|
|
522
|
+
if (event.type === 'error') {
|
|
523
|
+
this.bus.notifyError(agentId, event.error);
|
|
524
|
+
this.emitWorkspaceStatus();
|
|
525
|
+
}
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
// Inject only undelivered (pending) bus messages addressed to this agent
|
|
529
|
+
const pendingMsgs = this.bus.getPendingMessagesFor(agentId)
|
|
530
|
+
.filter(m => m.from !== agentId); // don't inject own messages
|
|
531
|
+
for (const msg of pendingMsgs) {
|
|
532
|
+
const fromLabel = this.agents.get(msg.from)?.config.label || msg.from;
|
|
533
|
+
worker.injectMessage({
|
|
534
|
+
type: 'system',
|
|
535
|
+
subtype: 'bus_message',
|
|
536
|
+
content: `[From ${fromLabel}]: ${msg.payload.content || msg.payload.action}`,
|
|
537
|
+
timestamp: new Date(msg.timestamp).toISOString(),
|
|
538
|
+
});
|
|
539
|
+
// Mark as delivered + ACK so sender knows it was received
|
|
540
|
+
msg.status = 'done';
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Start from checkpoint if recovering from failure
|
|
544
|
+
const startStep = resumeFromCheckpoint && entry.state.lastCheckpoint !== undefined
|
|
545
|
+
? entry.state.lastCheckpoint + 1
|
|
546
|
+
: 0;
|
|
547
|
+
|
|
548
|
+
this.emitWorkspaceStatus();
|
|
549
|
+
|
|
550
|
+
// Execute (non-blocking — fire and forget, events handle the rest)
|
|
551
|
+
worker.execute(startStep, upstreamContext).catch(err => {
|
|
552
|
+
// Only set failed if worker didn't already handle it (avoid duplicate error events)
|
|
553
|
+
if (entry.state.taskStatus !== 'failed') {
|
|
554
|
+
entry.state.taskStatus = 'failed';
|
|
555
|
+
entry.state.error = err?.message || String(err);
|
|
556
|
+
this.emit('event', { type: 'error', agentId, error: entry.state.error! } satisfies WorkerEvent);
|
|
557
|
+
}
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** Run all agents — starts daemon if not active, then runs all ready agents */
|
|
562
|
+
async runAll(): Promise<void> {
|
|
563
|
+
if (!this.daemonActive) {
|
|
564
|
+
return this.startDaemon();
|
|
565
|
+
}
|
|
566
|
+
const ready = this.getDaemonReadyAgents();
|
|
567
|
+
await Promise.all(ready.map(id => this.runAgentDaemon(id)));
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/** Run a single agent in daemon mode. force=true resets failed/interrupted agents. triggerMessageId tracks which bus message started this. */
|
|
571
|
+
async runAgentDaemon(agentId: string, userInput?: string, force = false, triggerMessageId?: string): Promise<void> {
|
|
572
|
+
const entry = this.agents.get(agentId);
|
|
573
|
+
if (!entry) throw new Error(`Agent "${agentId}" not found`);
|
|
574
|
+
|
|
575
|
+
if (entry.config.type === 'input') {
|
|
576
|
+
if (userInput) this.completeInput(agentId, userInput);
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
if (entry.state.taskStatus === 'running' && !force) return;
|
|
581
|
+
// Already has a daemon worker running → skip (unless force retry)
|
|
582
|
+
if (entry.worker && entry.state.smithStatus === 'active' && !force) return;
|
|
583
|
+
|
|
584
|
+
// Already done → enter daemon listening directly (don't re-run steps)
|
|
585
|
+
if (entry.state.taskStatus === 'done' && !force) {
|
|
586
|
+
return this.enterDaemonListening(agentId);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
if (!force) {
|
|
590
|
+
// Failed → leave as-is, user must retry explicitly
|
|
591
|
+
if (entry.state.taskStatus === 'failed') return;
|
|
592
|
+
// waiting_approval → leave as-is
|
|
593
|
+
if (this.approvalQueue.has(agentId)) return;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// Reset state for fresh start — preserve smithStatus and mode
|
|
597
|
+
if (entry.state.taskStatus !== 'idle') {
|
|
598
|
+
this.approvalQueue.delete(agentId);
|
|
599
|
+
if (entry.worker) entry.worker.stop();
|
|
600
|
+
entry.worker = null;
|
|
601
|
+
entry.state = {
|
|
602
|
+
smithStatus: entry.state.smithStatus,
|
|
603
|
+
mode: entry.state.mode,
|
|
604
|
+
taskStatus: 'idle',
|
|
605
|
+
history: [],
|
|
606
|
+
artifacts: [],
|
|
607
|
+
cliSessionId: entry.state.cliSessionId, // preserve session for --resume
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// Ensure smith is active when daemon starts this agent
|
|
612
|
+
if (this.daemonActive && entry.state.smithStatus !== 'active') {
|
|
613
|
+
entry.state.smithStatus = 'active';
|
|
614
|
+
this.emit('event', { type: 'smith_status', agentId, smithStatus: 'active', mode: entry.state.mode } satisfies WorkerEvent);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
const { config } = entry;
|
|
618
|
+
|
|
619
|
+
// Check dependencies
|
|
620
|
+
for (const depId of config.dependsOn) {
|
|
621
|
+
const dep = this.agents.get(depId);
|
|
622
|
+
if (!dep) throw new Error(`Dependency "${depId}" not found`);
|
|
623
|
+
if (force) {
|
|
624
|
+
// Manual trigger: only require upstream smith to be active (online)
|
|
625
|
+
if (dep.config.type !== 'input' && dep.state.smithStatus !== 'active') {
|
|
626
|
+
throw new Error(`Dependency "${dep.config.label}" smith is not active — start daemon first`);
|
|
627
|
+
}
|
|
628
|
+
} else {
|
|
629
|
+
// Auto trigger: require upstream task completed
|
|
630
|
+
if (dep.state.taskStatus !== 'done') {
|
|
631
|
+
throw new Error(`Dependency "${dep.config.label}" not completed yet`);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
let upstreamContext = this.buildUpstreamContext(config);
|
|
637
|
+
if (userInput) {
|
|
638
|
+
const prefix = '## Additional Instructions:\n' + userInput;
|
|
639
|
+
upstreamContext = upstreamContext ? prefix + '\n\n---\n\n' + upstreamContext : prefix;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
const backend = this.createBackend(config, agentId);
|
|
643
|
+
const memory = loadMemory(this.workspaceId, agentId);
|
|
644
|
+
const memoryContext = formatMemoryForPrompt(memory);
|
|
645
|
+
const peerAgentIds = Array.from(this.agents.keys()).filter(id => id !== agentId);
|
|
646
|
+
|
|
647
|
+
const worker = new AgentWorker({
|
|
648
|
+
config, backend,
|
|
649
|
+
projectPath: this.projectPath, workspaceId: this.workspaceId,
|
|
650
|
+
peerAgentIds,
|
|
651
|
+
memoryContext: memoryContext || undefined,
|
|
652
|
+
onBusSend: (to, content) => {
|
|
653
|
+
this.bus.send(agentId, to, 'notify', { action: 'agent_message', content });
|
|
654
|
+
},
|
|
655
|
+
onBusRequest: async (to, question) => {
|
|
656
|
+
const response = await this.bus.request(agentId, to, { action: 'question', content: question });
|
|
657
|
+
return response.payload.content || '(no response)';
|
|
658
|
+
},
|
|
659
|
+
onMessageDone: (messageId) => {
|
|
660
|
+
const busMsg = this.bus.getLog().find(m => m.id === messageId);
|
|
661
|
+
if (busMsg) {
|
|
662
|
+
busMsg.status = 'done';
|
|
663
|
+
this.emit('event', { type: 'bus_message_status', messageId, status: 'done' } as any);
|
|
664
|
+
this.emitAgentsChanged();
|
|
665
|
+
}
|
|
666
|
+
},
|
|
667
|
+
onMessageFailed: (messageId) => {
|
|
668
|
+
const busMsg = this.bus.getLog().find(m => m.id === messageId);
|
|
669
|
+
if (busMsg) {
|
|
670
|
+
busMsg.status = 'failed';
|
|
671
|
+
this.emit('event', { type: 'bus_message_status', messageId, status: 'failed' } as any);
|
|
672
|
+
this.emitAgentsChanged();
|
|
673
|
+
}
|
|
674
|
+
},
|
|
675
|
+
onMemoryUpdate: (stepResults) => {
|
|
676
|
+
try {
|
|
677
|
+
const observations = stepResults.flatMap((r, i) =>
|
|
678
|
+
parseStepToObservations(config.steps[i]?.label || `Step ${i}`, r, entry.state.artifacts)
|
|
679
|
+
);
|
|
680
|
+
for (const obs of observations) addObservation(this.workspaceId, agentId, config.label, config.role, obs);
|
|
681
|
+
const stepLabels = config.steps.map(s => s.label);
|
|
682
|
+
const summary = buildSessionSummary(stepLabels, stepResults, entry.state.artifacts);
|
|
683
|
+
addSessionSummary(this.workspaceId, agentId, summary);
|
|
684
|
+
} catch {}
|
|
685
|
+
},
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
entry.worker = worker;
|
|
689
|
+
|
|
690
|
+
// Track trigger message so smith can mark it done/failed on completion
|
|
691
|
+
if (triggerMessageId) {
|
|
692
|
+
worker.setProcessingMessage(triggerMessageId);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// Forward events (same as runAgent)
|
|
696
|
+
worker.on('event', (event: WorkerEvent) => {
|
|
697
|
+
if (event.type === 'task_status') {
|
|
698
|
+
entry.state.taskStatus = event.taskStatus;
|
|
699
|
+
entry.state.error = event.error;
|
|
700
|
+
if (event.taskStatus === 'running') entry.state.startedAt = Date.now();
|
|
701
|
+
const workerState = worker.getState();
|
|
702
|
+
entry.state.daemonIteration = workerState.daemonIteration;
|
|
703
|
+
}
|
|
704
|
+
if (event.type === 'smith_status') {
|
|
705
|
+
entry.state.smithStatus = event.smithStatus;
|
|
706
|
+
entry.state.mode = event.mode;
|
|
707
|
+
}
|
|
708
|
+
if (event.type === 'log') {
|
|
709
|
+
appendAgentLog(this.workspaceId, agentId, event.entry).catch(() => {});
|
|
710
|
+
}
|
|
711
|
+
this.emit('event', event);
|
|
712
|
+
if (event.type === 'task_status' || event.type === 'smith_status') {
|
|
713
|
+
this.updateAgentLiveness(agentId);
|
|
714
|
+
}
|
|
715
|
+
if (event.type === 'step' && event.stepIndex >= 0) {
|
|
716
|
+
const step = config.steps[event.stepIndex];
|
|
717
|
+
if (step) this.bus.notifyStepComplete(agentId, step.label);
|
|
718
|
+
}
|
|
719
|
+
if (event.type === 'done') {
|
|
720
|
+
this.handleAgentDone(agentId, entry, event.summary);
|
|
721
|
+
}
|
|
722
|
+
if (event.type === 'error') {
|
|
723
|
+
this.bus.notifyError(agentId, event.error);
|
|
724
|
+
this.emitWorkspaceStatus();
|
|
725
|
+
}
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
// Inject pending messages
|
|
729
|
+
const pendingMsgs = this.bus.getPendingMessagesFor(agentId)
|
|
730
|
+
.filter(m => m.from !== agentId);
|
|
731
|
+
for (const msg of pendingMsgs) {
|
|
732
|
+
const fromLabel = this.agents.get(msg.from)?.config.label || msg.from;
|
|
733
|
+
worker.injectMessage({
|
|
734
|
+
type: 'system', subtype: 'bus_message',
|
|
735
|
+
content: `[From ${fromLabel}]: ${msg.payload.content || msg.payload.action}`,
|
|
736
|
+
timestamp: new Date(msg.timestamp).toISOString(),
|
|
737
|
+
});
|
|
738
|
+
msg.status = 'done';
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
this.emitWorkspaceStatus();
|
|
742
|
+
|
|
743
|
+
// Execute in daemon mode (non-blocking)
|
|
744
|
+
worker.executeDaemon(0, upstreamContext).catch(err => {
|
|
745
|
+
if (entry.state.taskStatus !== 'failed') {
|
|
746
|
+
entry.state.taskStatus = 'failed';
|
|
747
|
+
entry.state.error = err?.message || String(err);
|
|
748
|
+
this.emit('event', { type: 'error', agentId, error: entry.state.error! } satisfies WorkerEvent);
|
|
749
|
+
}
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/** Start all agents in daemon mode — orchestrator manages each smith's lifecycle */
|
|
754
|
+
async startDaemon(): Promise<void> {
|
|
755
|
+
if (this.daemonActive) return;
|
|
756
|
+
this.daemonActive = true;
|
|
757
|
+
console.log(`[workspace] Starting daemon mode...`);
|
|
758
|
+
|
|
759
|
+
// Clean up stale state from previous run
|
|
760
|
+
this.bus.markAllRunningAsFailed();
|
|
761
|
+
|
|
762
|
+
// Install forge skills globally (once per daemon start)
|
|
763
|
+
try {
|
|
764
|
+
installForgeSkills(this.projectPath, this.workspaceId, '', Number(process.env.PORT) || 8403);
|
|
765
|
+
} catch {}
|
|
766
|
+
|
|
767
|
+
// Start each smith one by one, verify each starts correctly
|
|
768
|
+
let started = 0;
|
|
769
|
+
let failed = 0;
|
|
770
|
+
for (const [id, entry] of this.agents) {
|
|
771
|
+
if (entry.config.type === 'input') continue;
|
|
772
|
+
|
|
773
|
+
// Kill any stale worker from previous run
|
|
774
|
+
if (entry.worker) {
|
|
775
|
+
entry.worker.stop();
|
|
776
|
+
entry.worker = null;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// Stop any existing message loop
|
|
780
|
+
this.stopMessageLoop(id);
|
|
781
|
+
|
|
782
|
+
try {
|
|
783
|
+
// 1. Start daemon listening loop (creates worker)
|
|
784
|
+
this.enterDaemonListening(id);
|
|
785
|
+
|
|
786
|
+
// 2. Verify worker was created
|
|
787
|
+
if (!entry.worker) {
|
|
788
|
+
throw new Error('Worker not created');
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// 3. Set smith status to active
|
|
792
|
+
entry.state.smithStatus = 'active';
|
|
793
|
+
entry.state.mode = 'auto';
|
|
794
|
+
entry.state.error = undefined;
|
|
795
|
+
|
|
796
|
+
// 4. Start message consumption loop
|
|
797
|
+
this.startMessageLoop(id);
|
|
798
|
+
|
|
799
|
+
// 5. Update liveness for bus routing
|
|
800
|
+
this.updateAgentLiveness(id);
|
|
801
|
+
|
|
802
|
+
// 6. Notify frontend
|
|
803
|
+
this.emit('event', { type: 'smith_status', agentId: id, smithStatus: 'active', mode: 'auto' } satisfies WorkerEvent);
|
|
804
|
+
|
|
805
|
+
started++;
|
|
806
|
+
console.log(`[daemon] ✓ ${entry.config.label}: active (task=${entry.state.taskStatus})`);
|
|
807
|
+
} catch (err: any) {
|
|
808
|
+
entry.state.smithStatus = 'down';
|
|
809
|
+
entry.state.error = `Failed to start: ${err.message}`;
|
|
810
|
+
this.emit('event', { type: 'smith_status', agentId: id, smithStatus: 'down', mode: 'auto' } satisfies WorkerEvent);
|
|
811
|
+
failed++;
|
|
812
|
+
console.error(`[daemon] ✗ ${entry.config.label}: failed — ${err.message}`);
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// Start watch loops for agents with watch config
|
|
817
|
+
this.watchManager.start();
|
|
818
|
+
|
|
819
|
+
console.log(`[workspace] Daemon started: ${started} smiths active, ${failed} failed`);
|
|
820
|
+
this.emitAgentsChanged();
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/** Get agents that can start in daemon mode (idle, done — with deps met) */
|
|
824
|
+
private getDaemonReadyAgents(): string[] {
|
|
825
|
+
const ready: string[] = [];
|
|
826
|
+
for (const [id, entry] of this.agents) {
|
|
827
|
+
if (entry.config.type === 'input') continue;
|
|
828
|
+
if (entry.state.taskStatus === 'running' || entry.state.smithStatus === 'active') {
|
|
829
|
+
console.log(`[daemon] ${entry.config.label}: already smithStatus=${entry.state.smithStatus} taskStatus=${entry.state.taskStatus}`);
|
|
830
|
+
continue;
|
|
831
|
+
}
|
|
832
|
+
const allDepsDone = entry.config.dependsOn.every(depId => {
|
|
833
|
+
const dep = this.agents.get(depId);
|
|
834
|
+
return dep && (dep.state.taskStatus === 'done');
|
|
835
|
+
});
|
|
836
|
+
if (allDepsDone) {
|
|
837
|
+
console.log(`[daemon] ${entry.config.label}: ready (taskStatus=${entry.state.taskStatus})`);
|
|
838
|
+
ready.push(id);
|
|
839
|
+
} else {
|
|
840
|
+
const unmet = entry.config.dependsOn.filter(d => {
|
|
841
|
+
const dep = this.agents.get(d);
|
|
842
|
+
return !dep || (dep.state.taskStatus !== 'done');
|
|
843
|
+
}).map(d => this.agents.get(d)?.config.label || d);
|
|
844
|
+
console.log(`[daemon] ${entry.config.label}: not ready — deps unmet: ${unmet.join(', ')} (taskStatus=${entry.state.taskStatus})`);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
return ready;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/** Put a done agent into daemon listening mode without re-running steps */
|
|
851
|
+
private enterDaemonListening(agentId: string): void {
|
|
852
|
+
const entry = this.agents.get(agentId);
|
|
853
|
+
if (!entry) return;
|
|
854
|
+
|
|
855
|
+
const { config } = entry;
|
|
856
|
+
|
|
857
|
+
// TODO: per-smith install hook for future use (commands, custom skills, etc.)
|
|
858
|
+
// Skills are installed globally in startDaemon, not per-smith
|
|
859
|
+
|
|
860
|
+
const backend = this.createBackend(config, agentId);
|
|
861
|
+
const peerAgentIds = Array.from(this.agents.keys()).filter(id => id !== agentId);
|
|
862
|
+
|
|
863
|
+
const worker = new AgentWorker({
|
|
864
|
+
config, backend,
|
|
865
|
+
projectPath: this.projectPath, workspaceId: this.workspaceId,
|
|
866
|
+
peerAgentIds,
|
|
867
|
+
initialTaskStatus: entry.state.taskStatus, // preserve current task status
|
|
868
|
+
onBusSend: (to, content) => {
|
|
869
|
+
this.bus.send(agentId, to, 'notify', { action: 'agent_message', content });
|
|
870
|
+
},
|
|
871
|
+
onBusRequest: async (to, question) => {
|
|
872
|
+
const response = await this.bus.request(agentId, to, { action: 'question', content: question });
|
|
873
|
+
return response.payload.content || '(no response)';
|
|
874
|
+
},
|
|
875
|
+
onMessageDone: (messageId) => {
|
|
876
|
+
const busMsg = this.bus.getLog().find(m => m.id === messageId);
|
|
877
|
+
if (busMsg) {
|
|
878
|
+
busMsg.status = 'done';
|
|
879
|
+
this.emit('event', { type: 'bus_message_status', messageId, status: 'done' } as any);
|
|
880
|
+
this.emitAgentsChanged();
|
|
881
|
+
}
|
|
882
|
+
},
|
|
883
|
+
onMessageFailed: (messageId) => {
|
|
884
|
+
const busMsg = this.bus.getLog().find(m => m.id === messageId);
|
|
885
|
+
if (busMsg) {
|
|
886
|
+
busMsg.status = 'failed';
|
|
887
|
+
this.emit('event', { type: 'bus_message_status', messageId, status: 'failed' } as any);
|
|
888
|
+
this.emitAgentsChanged();
|
|
889
|
+
}
|
|
890
|
+
},
|
|
891
|
+
});
|
|
892
|
+
|
|
893
|
+
entry.worker = worker;
|
|
894
|
+
|
|
895
|
+
// Forward events (same handler as runAgentDaemon)
|
|
896
|
+
worker.on('event', (event: WorkerEvent) => {
|
|
897
|
+
if (event.type === 'task_status') {
|
|
898
|
+
entry.state.taskStatus = event.taskStatus;
|
|
899
|
+
entry.state.error = event.error;
|
|
900
|
+
const workerState = worker.getState();
|
|
901
|
+
entry.state.daemonIteration = workerState.daemonIteration;
|
|
902
|
+
}
|
|
903
|
+
if (event.type === 'smith_status') {
|
|
904
|
+
entry.state.smithStatus = event.smithStatus;
|
|
905
|
+
entry.state.mode = event.mode;
|
|
906
|
+
}
|
|
907
|
+
if (event.type === 'log') {
|
|
908
|
+
appendAgentLog(this.workspaceId, agentId, event.entry).catch(() => {});
|
|
909
|
+
}
|
|
910
|
+
this.emit('event', event);
|
|
911
|
+
if (event.type === 'task_status' || event.type === 'smith_status') {
|
|
912
|
+
this.updateAgentLiveness(agentId);
|
|
913
|
+
}
|
|
914
|
+
if (event.type === 'done') {
|
|
915
|
+
this.handleAgentDone(agentId, entry, event.summary);
|
|
916
|
+
}
|
|
917
|
+
if (event.type === 'error') {
|
|
918
|
+
this.bus.notifyError(agentId, event.error);
|
|
919
|
+
}
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
// Message loop (startMessageLoop) handles auto-consumption of pending messages
|
|
923
|
+
|
|
924
|
+
console.log(`[workspace] Agent "${config.label}" entering daemon listening (task=${entry.state.taskStatus})`);
|
|
925
|
+
|
|
926
|
+
// executeDaemon with skipSteps=true → goes directly to listening loop
|
|
927
|
+
worker.executeDaemon(0, undefined, true).catch(err => {
|
|
928
|
+
console.error(`[workspace] enterDaemonListening error for ${config.label}:`, err.message);
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
/** Stop all agents (exit daemon mode) */
|
|
933
|
+
/** Stop all agents — orchestrator shuts down each smith */
|
|
934
|
+
stopDaemon(): void {
|
|
935
|
+
this.daemonActive = false;
|
|
936
|
+
console.log('[workspace] Stopping daemon...');
|
|
937
|
+
|
|
938
|
+
for (const [id, entry] of this.agents) {
|
|
939
|
+
if (entry.config.type === 'input') continue;
|
|
940
|
+
|
|
941
|
+
// 1. Stop message loop
|
|
942
|
+
this.stopMessageLoop(id);
|
|
943
|
+
|
|
944
|
+
// 2. Stop worker
|
|
945
|
+
if (entry.worker) {
|
|
946
|
+
entry.worker.stop();
|
|
947
|
+
entry.worker = null;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
// 3. Set smith down
|
|
951
|
+
entry.state.smithStatus = 'down';
|
|
952
|
+
entry.state.error = undefined;
|
|
953
|
+
this.updateAgentLiveness(id);
|
|
954
|
+
this.emit('event', { type: 'smith_status', agentId: id, smithStatus: 'down', mode: entry.state.mode } satisfies WorkerEvent);
|
|
955
|
+
|
|
956
|
+
console.log(`[daemon] ■ ${entry.config.label}: stopped`);
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
// Mark running messages as failed
|
|
960
|
+
this.bus.markAllRunningAsFailed();
|
|
961
|
+
this.emitAgentsChanged();
|
|
962
|
+
this.watchManager.stop();
|
|
963
|
+
console.log('[workspace] Daemon stopped');
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/** Handle watch alert based on agent's configured action */
|
|
967
|
+
private handleWatchAlert(agentId: string, summary: string): void {
|
|
968
|
+
const entry = this.agents.get(agentId);
|
|
969
|
+
if (!entry) return;
|
|
970
|
+
const action = entry.config.watch?.action || 'log';
|
|
971
|
+
|
|
972
|
+
if (action === 'log') {
|
|
973
|
+
// Already logged by watch-manager, nothing more to do
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
if (action === 'analyze') {
|
|
978
|
+
// Auto-wake agent to analyze changes (skip if busy/manual)
|
|
979
|
+
if (entry.state.mode === 'manual' || entry.state.taskStatus === 'running') return;
|
|
980
|
+
if (!entry.worker?.isListening()) return;
|
|
981
|
+
|
|
982
|
+
const prompt = entry.config.watch?.prompt || 'Analyze the following changes and produce a report:';
|
|
983
|
+
const logEntry = {
|
|
984
|
+
type: 'system' as const,
|
|
985
|
+
subtype: 'watch_trigger',
|
|
986
|
+
content: `[Watch] ${prompt}\n\n${summary}`,
|
|
987
|
+
timestamp: new Date().toISOString(),
|
|
988
|
+
};
|
|
989
|
+
entry.worker.wake({ type: 'bus_message', messages: [logEntry] });
|
|
990
|
+
console.log(`[watch] ${entry.config.label}: auto-analyzing detected changes`);
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
if (action === 'approve') {
|
|
995
|
+
// Create pending approval — user must click to trigger analysis
|
|
996
|
+
this.bus.send('_watch', agentId, 'notify', {
|
|
997
|
+
action: 'watch_changes',
|
|
998
|
+
content: `Watch detected changes (awaiting approval):\n${summary}`,
|
|
999
|
+
});
|
|
1000
|
+
this.approvalQueue.add(agentId);
|
|
1001
|
+
this.emit('event', { type: 'approval_required', agentId, upstreamId: '_watch' } satisfies OrchestratorEvent);
|
|
1002
|
+
console.log(`[watch] ${entry.config.label}: changes detected, awaiting approval`);
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
/** Check if daemon mode is active */
|
|
1007
|
+
isDaemonActive(): boolean {
|
|
1008
|
+
return this.daemonActive;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
/** Pause a running agent */
|
|
1012
|
+
pauseAgent(agentId: string): void {
|
|
1013
|
+
const entry = this.agents.get(agentId);
|
|
1014
|
+
entry?.worker?.pause();
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
/** Resume a paused agent */
|
|
1018
|
+
resumeAgent(agentId: string): void {
|
|
1019
|
+
const entry = this.agents.get(agentId);
|
|
1020
|
+
entry?.worker?.resume();
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
/** Stop a running agent */
|
|
1024
|
+
stopAgent(agentId: string): void {
|
|
1025
|
+
const entry = this.agents.get(agentId);
|
|
1026
|
+
entry?.worker?.stop();
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
/** Retry a failed agent from its last checkpoint */
|
|
1030
|
+
async retryAgent(agentId: string): Promise<void> {
|
|
1031
|
+
const entry = this.agents.get(agentId);
|
|
1032
|
+
if (!entry) throw new Error(`Agent "${agentId}" not found`);
|
|
1033
|
+
if (entry.state.taskStatus === 'running') {
|
|
1034
|
+
throw new Error(`Agent "${entry.config.label}" is already running`);
|
|
1035
|
+
}
|
|
1036
|
+
if (entry.state.taskStatus !== 'failed') {
|
|
1037
|
+
throw new Error(`Agent "${entry.config.label}" is ${entry.state.taskStatus}, not failed`);
|
|
1038
|
+
}
|
|
1039
|
+
// force=true: skip dep taskStatus check, only require upstream smith active
|
|
1040
|
+
await this.runAgent(agentId, undefined, true);
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
/** Send a message to a running agent (human intervention) */
|
|
1044
|
+
/** Send a message to a smith — becomes a pending inbox message, processed by message loop */
|
|
1045
|
+
sendMessageToAgent(agentId: string, content: string): void {
|
|
1046
|
+
const entry = this.agents.get(agentId);
|
|
1047
|
+
if (!entry) return;
|
|
1048
|
+
|
|
1049
|
+
// Send via bus → becomes pending inbox message → message loop will consume it
|
|
1050
|
+
this.bus.send('user', agentId, 'notify', {
|
|
1051
|
+
action: 'user_message',
|
|
1052
|
+
content,
|
|
1053
|
+
});
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
/** Approve a waiting agent to start execution */
|
|
1057
|
+
approveAgent(agentId: string): void {
|
|
1058
|
+
if (!this.approvalQueue.has(agentId)) return;
|
|
1059
|
+
this.approvalQueue.delete(agentId);
|
|
1060
|
+
this.runAgent(agentId).catch(() => {});
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
/** Save tmux session name for an agent (for reattach after refresh) */
|
|
1064
|
+
setTmuxSession(agentId: string, sessionName: string): void {
|
|
1065
|
+
const entry = this.agents.get(agentId);
|
|
1066
|
+
if (!entry) return;
|
|
1067
|
+
entry.state.tmuxSession = sessionName;
|
|
1068
|
+
this.saveNow();
|
|
1069
|
+
this.emitAgentsChanged();
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
/** Switch an agent to manual mode (user operates in terminal) */
|
|
1073
|
+
setManualMode(agentId: string): void {
|
|
1074
|
+
const entry = this.agents.get(agentId);
|
|
1075
|
+
if (!entry) return;
|
|
1076
|
+
entry.state.mode = 'manual';
|
|
1077
|
+
this.emit('event', { type: 'smith_status', agentId, smithStatus: entry.state.smithStatus, mode: 'manual' } satisfies WorkerEvent);
|
|
1078
|
+
this.emitAgentsChanged();
|
|
1079
|
+
this.saveNow();
|
|
1080
|
+
console.log(`[workspace] Agent "${entry.config.label}" switched to manual mode`);
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
/** Re-enter daemon mode for an agent after manual terminal is closed */
|
|
1084
|
+
restartAgentDaemon(agentId: string): void {
|
|
1085
|
+
if (!this.daemonActive) return;
|
|
1086
|
+
const entry = this.agents.get(agentId);
|
|
1087
|
+
if (!entry || entry.config.type === 'input') return;
|
|
1088
|
+
|
|
1089
|
+
entry.state.mode = 'auto';
|
|
1090
|
+
entry.state.error = undefined;
|
|
1091
|
+
|
|
1092
|
+
// Recreate worker if needed (resetAgent kills worker)
|
|
1093
|
+
if (!entry.worker) {
|
|
1094
|
+
this.enterDaemonListening(agentId);
|
|
1095
|
+
this.startMessageLoop(agentId);
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
entry.state.smithStatus = 'active';
|
|
1099
|
+
this.emit('event', { type: 'smith_status', agentId, smithStatus: 'active', mode: 'auto' } satisfies WorkerEvent);
|
|
1100
|
+
this.emitAgentsChanged();
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
/** Complete a manual agent — called by forge-done skill from terminal */
|
|
1104
|
+
completeManualAgent(agentId: string, changedFiles: string[]): void {
|
|
1105
|
+
const entry = this.agents.get(agentId);
|
|
1106
|
+
if (!entry) return;
|
|
1107
|
+
|
|
1108
|
+
entry.state.taskStatus = 'done';
|
|
1109
|
+
entry.state.mode = 'auto'; // clear manual mode
|
|
1110
|
+
entry.state.completedAt = Date.now();
|
|
1111
|
+
entry.state.artifacts = changedFiles.map(f => ({ type: 'file' as const, path: f }));
|
|
1112
|
+
|
|
1113
|
+
console.log(`[workspace] Manual agent "${entry.config.label}" marked done. ${changedFiles.length} files changed.`);
|
|
1114
|
+
|
|
1115
|
+
this.emit('event', { type: 'task_status', agentId, taskStatus: 'done' } satisfies WorkerEvent);
|
|
1116
|
+
this.emit('event', { type: 'done', agentId, summary: `Manual: ${changedFiles.length} files changed` } satisfies WorkerEvent);
|
|
1117
|
+
this.emitAgentsChanged();
|
|
1118
|
+
|
|
1119
|
+
// Notify ALL agents that depend on this one (not just direct downstream)
|
|
1120
|
+
this.bus.notifyTaskComplete(agentId, changedFiles, `Manual work: ${changedFiles.length} files`);
|
|
1121
|
+
|
|
1122
|
+
// Send individual bus messages to all downstream agents so they know
|
|
1123
|
+
for (const [id, other] of this.agents) {
|
|
1124
|
+
if (id === agentId || other.config.type === 'input') continue;
|
|
1125
|
+
if (other.config.dependsOn.includes(agentId)) {
|
|
1126
|
+
this.bus.send(agentId, id, 'notify', {
|
|
1127
|
+
action: 'update_notify',
|
|
1128
|
+
content: `${entry.config.label} completed manual work: ${changedFiles.length} files changed`,
|
|
1129
|
+
files: changedFiles,
|
|
1130
|
+
});
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
if (this.daemonActive) {
|
|
1135
|
+
this.broadcastCompletion(agentId);
|
|
1136
|
+
}
|
|
1137
|
+
this.notifyDownstreamForRevalidation(agentId, changedFiles);
|
|
1138
|
+
this.emitWorkspaceStatus();
|
|
1139
|
+
this.checkWorkspaceComplete();
|
|
1140
|
+
this.saveNow();
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
/** Reject an approval (set agent back to idle) */
|
|
1144
|
+
rejectApproval(agentId: string): void {
|
|
1145
|
+
this.approvalQueue.delete(agentId);
|
|
1146
|
+
const entry = this.agents.get(agentId);
|
|
1147
|
+
if (entry) {
|
|
1148
|
+
entry.state.taskStatus = 'idle';
|
|
1149
|
+
this.emit('event', { type: 'task_status', agentId, taskStatus: 'idle' } satisfies WorkerEvent);
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
// ─── Bus Access ────────────────────────────────────────
|
|
1154
|
+
|
|
1155
|
+
getBus(): AgentBus {
|
|
1156
|
+
return this.bus;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
getBusLog(): readonly BusMessage[] {
|
|
1160
|
+
return this.bus.getLog();
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
// ─── State Snapshot (for persistence) ──────────────────
|
|
1164
|
+
|
|
1165
|
+
/** Get full workspace state for auto-save */
|
|
1166
|
+
getFullState(): WorkspaceState {
|
|
1167
|
+
return {
|
|
1168
|
+
id: this.workspaceId,
|
|
1169
|
+
projectPath: this.projectPath,
|
|
1170
|
+
projectName: this.projectName,
|
|
1171
|
+
agents: Array.from(this.agents.values()).map(e => e.config),
|
|
1172
|
+
agentStates: this.getAllAgentStates(),
|
|
1173
|
+
nodePositions: {},
|
|
1174
|
+
busLog: [...this.bus.getLog()],
|
|
1175
|
+
busOutbox: this.bus.getAllOutbox(),
|
|
1176
|
+
createdAt: this.createdAt,
|
|
1177
|
+
updatedAt: Date.now(),
|
|
1178
|
+
};
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
getSnapshot(): {
|
|
1182
|
+
agents: WorkspaceAgentConfig[];
|
|
1183
|
+
agentStates: Record<string, AgentState>;
|
|
1184
|
+
busLog: BusMessage[];
|
|
1185
|
+
daemonActive: boolean;
|
|
1186
|
+
} {
|
|
1187
|
+
return {
|
|
1188
|
+
agents: Array.from(this.agents.values()).map(e => e.config),
|
|
1189
|
+
agentStates: this.getAllAgentStates(),
|
|
1190
|
+
busLog: [...this.bus.getLog()],
|
|
1191
|
+
daemonActive: this.daemonActive,
|
|
1192
|
+
};
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
/** Restore from persisted state */
|
|
1196
|
+
loadSnapshot(data: {
|
|
1197
|
+
agents: WorkspaceAgentConfig[];
|
|
1198
|
+
agentStates: Record<string, AgentState>;
|
|
1199
|
+
busLog: BusMessage[];
|
|
1200
|
+
busOutbox?: Record<string, BusMessage[]>;
|
|
1201
|
+
}): void {
|
|
1202
|
+
this.agents.clear();
|
|
1203
|
+
this.daemonActive = false; // Reset daemon — user must click Start Daemon again after restart
|
|
1204
|
+
for (const config of data.agents) {
|
|
1205
|
+
const state = data.agentStates[config.id] || { smithStatus: 'down' as const, mode: 'auto' as const, taskStatus: 'idle' as const, history: [], artifacts: [] };
|
|
1206
|
+
|
|
1207
|
+
// Migrate old format if loading from pre-two-layer state
|
|
1208
|
+
if ('status' in state && !('smithStatus' in state)) {
|
|
1209
|
+
const oldStatus = (state as any).status;
|
|
1210
|
+
(state as any).smithStatus = 'down';
|
|
1211
|
+
(state as any).mode = (state as any).runMode || 'auto';
|
|
1212
|
+
(state as any).taskStatus = (oldStatus === 'running' || oldStatus === 'listening') ? 'idle' :
|
|
1213
|
+
(oldStatus === 'interrupted') ? 'idle' :
|
|
1214
|
+
(oldStatus === 'waiting_approval') ? 'idle' :
|
|
1215
|
+
(oldStatus === 'paused') ? 'idle' :
|
|
1216
|
+
oldStatus;
|
|
1217
|
+
delete (state as any).status;
|
|
1218
|
+
delete (state as any).runMode;
|
|
1219
|
+
delete (state as any).daemonMode;
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
// Mark running agents as failed (interrupted by restart)
|
|
1223
|
+
if (state.taskStatus === 'running') {
|
|
1224
|
+
state.taskStatus = 'failed';
|
|
1225
|
+
state.error = 'Interrupted by restart';
|
|
1226
|
+
}
|
|
1227
|
+
// Smith is down after restart (no daemon loop running)
|
|
1228
|
+
state.smithStatus = 'down';
|
|
1229
|
+
state.daemonIteration = undefined;
|
|
1230
|
+
this.agents.set(config.id, { config, worker: null, state });
|
|
1231
|
+
}
|
|
1232
|
+
this.bus.loadLog(data.busLog);
|
|
1233
|
+
if (data.busOutbox) {
|
|
1234
|
+
this.bus.loadOutbox(data.busOutbox);
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
// Mark all pending messages as failed (they were lost on shutdown)
|
|
1238
|
+
// Users can retry agents manually if needed
|
|
1239
|
+
// Running messages from before crash → failed (pending stays pending for retry)
|
|
1240
|
+
this.bus.markAllRunningAsFailed();
|
|
1241
|
+
|
|
1242
|
+
// Initialize liveness for all loaded agents so bus delivery works
|
|
1243
|
+
for (const [agentId] of this.agents) {
|
|
1244
|
+
this.updateAgentLiveness(agentId);
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
/** Stop all agents, save final state, and clean up */
|
|
1249
|
+
shutdown(): void {
|
|
1250
|
+
this.stopAllMessageLoops();
|
|
1251
|
+
stopAutoSave(this.workspaceId);
|
|
1252
|
+
// Sync save — must complete before process exits
|
|
1253
|
+
try { saveWorkspaceSync(this.getFullState()); } catch (err) {
|
|
1254
|
+
console.error(`[workspace] Failed to save on shutdown:`, err);
|
|
1255
|
+
}
|
|
1256
|
+
for (const [, entry] of this.agents) {
|
|
1257
|
+
entry.worker?.stop();
|
|
1258
|
+
}
|
|
1259
|
+
this.bus.clear();
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
// ─── Private ───────────────────────────────────────────
|
|
1263
|
+
|
|
1264
|
+
private createBackend(config: WorkspaceAgentConfig, agentId?: string) {
|
|
1265
|
+
switch (config.backend) {
|
|
1266
|
+
case 'api':
|
|
1267
|
+
return new ApiBackend();
|
|
1268
|
+
case 'cli':
|
|
1269
|
+
default: {
|
|
1270
|
+
// Resume existing claude session if available
|
|
1271
|
+
const existingSessionId = agentId ? this.agents.get(agentId)?.state.cliSessionId : undefined;
|
|
1272
|
+
const backend = new CliBackend(existingSessionId);
|
|
1273
|
+
// Persist new sessionId back to agent state
|
|
1274
|
+
if (agentId) {
|
|
1275
|
+
backend.onSessionId = (id) => {
|
|
1276
|
+
const entry = this.agents.get(agentId);
|
|
1277
|
+
if (entry) entry.state.cliSessionId = id;
|
|
1278
|
+
};
|
|
1279
|
+
}
|
|
1280
|
+
return backend;
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
/** Build context string from upstream agents' outputs */
|
|
1286
|
+
private buildUpstreamContext(config: WorkspaceAgentConfig): string | undefined {
|
|
1287
|
+
if (config.dependsOn.length === 0) return undefined;
|
|
1288
|
+
|
|
1289
|
+
const sections: string[] = [];
|
|
1290
|
+
|
|
1291
|
+
for (const depId of config.dependsOn) {
|
|
1292
|
+
const dep = this.agents.get(depId);
|
|
1293
|
+
if (!dep || (dep.state.taskStatus !== 'done')) continue;
|
|
1294
|
+
|
|
1295
|
+
const label = dep.config.label;
|
|
1296
|
+
|
|
1297
|
+
// Input nodes: only send latest entry (not full history)
|
|
1298
|
+
if (dep.config.type === 'input') {
|
|
1299
|
+
const entries = dep.config.entries;
|
|
1300
|
+
if (entries && entries.length > 0) {
|
|
1301
|
+
const latest = entries[entries.length - 1];
|
|
1302
|
+
sections.push(`### ${label} (latest input):\n${latest.content}`);
|
|
1303
|
+
} else if (dep.config.content) {
|
|
1304
|
+
// Legacy fallback
|
|
1305
|
+
sections.push(`### ${label}:\n${dep.config.content}`);
|
|
1306
|
+
}
|
|
1307
|
+
continue;
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
const artifacts = dep.state.artifacts.filter(a => a.path);
|
|
1311
|
+
|
|
1312
|
+
if (artifacts.length === 0) {
|
|
1313
|
+
const lastResult = [...dep.state.history].reverse().find(h => h.type === 'result');
|
|
1314
|
+
if (lastResult) {
|
|
1315
|
+
sections.push(`### From ${label}:\n${lastResult.content}`);
|
|
1316
|
+
}
|
|
1317
|
+
continue;
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
// Read file artifacts
|
|
1321
|
+
for (const artifact of artifacts) {
|
|
1322
|
+
if (!artifact.path) continue;
|
|
1323
|
+
const fullPath = resolve(this.projectPath, artifact.path);
|
|
1324
|
+
try {
|
|
1325
|
+
if (existsSync(fullPath)) {
|
|
1326
|
+
const content = readFileSync(fullPath, 'utf-8');
|
|
1327
|
+
const truncated = content.length > 10000
|
|
1328
|
+
? content.slice(0, 10000) + '\n... (truncated)'
|
|
1329
|
+
: content;
|
|
1330
|
+
sections.push(`### From ${label} — ${artifact.path}:\n${truncated}`);
|
|
1331
|
+
}
|
|
1332
|
+
} catch {
|
|
1333
|
+
sections.push(`### From ${label} — ${artifact.path}: (could not read file)`);
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
if (sections.length === 0) return undefined;
|
|
1339
|
+
|
|
1340
|
+
let combined = sections.join('\n\n---\n\n');
|
|
1341
|
+
|
|
1342
|
+
// Cap total upstream context to ~50K chars (~12K tokens) to prevent token explosion
|
|
1343
|
+
const MAX_UPSTREAM_CHARS = 50000;
|
|
1344
|
+
if (combined.length > MAX_UPSTREAM_CHARS) {
|
|
1345
|
+
combined = combined.slice(0, MAX_UPSTREAM_CHARS) + '\n\n... (upstream context truncated, ' + combined.length + ' chars total)';
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
return combined;
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
/** After an agent completes, check if any downstream agents should be triggered */
|
|
1352
|
+
/**
|
|
1353
|
+
* Broadcast completion to all downstream agents via bus messages.
|
|
1354
|
+
* Replaces direct triggerDownstream — all execution is now message-driven.
|
|
1355
|
+
* If no artifacts/changes, no message is sent → downstream stays idle.
|
|
1356
|
+
*/
|
|
1357
|
+
/** Build causedBy from the message currently being processed */
|
|
1358
|
+
private buildCausedBy(agentId: string, entry: { worker: AgentWorker | null }): BusMessage['causedBy'] | undefined {
|
|
1359
|
+
const msgId = entry.worker?.getCurrentMessageId?.();
|
|
1360
|
+
if (!msgId) return undefined;
|
|
1361
|
+
const msg = this.bus.getLog().find(m => m.id === msgId);
|
|
1362
|
+
if (!msg) return undefined;
|
|
1363
|
+
return { messageId: msg.id, from: msg.from, to: msg.to };
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
/** Unified done handler: broadcast downstream or reply to sender based on message source */
|
|
1367
|
+
private handleAgentDone(agentId: string, entry: { config: WorkspaceAgentConfig; worker: AgentWorker | null; state: AgentState }, summary?: string): void {
|
|
1368
|
+
const files = entry.state.artifacts.filter(a => a.path).map(a => a.path!);
|
|
1369
|
+
console.log(`[workspace] Agent "${entry.config.label}" (${agentId}) completed. Artifacts: ${files.length}.`);
|
|
1370
|
+
|
|
1371
|
+
this.bus.notifyTaskComplete(agentId, files, summary);
|
|
1372
|
+
|
|
1373
|
+
// Check what message triggered this execution
|
|
1374
|
+
const causedBy = this.buildCausedBy(agentId, entry);
|
|
1375
|
+
const processedMsg = causedBy ? this.bus.getLog().find(m => m.id === causedBy.messageId) : null;
|
|
1376
|
+
|
|
1377
|
+
if (processedMsg && !this.isUpstream(processedMsg.from, agentId)) {
|
|
1378
|
+
// Processed a message from downstream — no extra reply needed.
|
|
1379
|
+
// The original message is already marked done via markMessageDone().
|
|
1380
|
+
// Sender can check their outbox message status. Only broadcast to downstream.
|
|
1381
|
+
const senderLabel = this.agents.get(processedMsg.from)?.config.label || processedMsg.from;
|
|
1382
|
+
console.log(`[bus] ${entry.config.label}: processed request from ${senderLabel} — marked done, no reply`);
|
|
1383
|
+
// Still broadcast to own downstream (e.g., QA processed Engineer's msg → notify Reviewer)
|
|
1384
|
+
this.broadcastCompletion(agentId, causedBy);
|
|
1385
|
+
} else {
|
|
1386
|
+
// Normal upstream completion or initial execution → broadcast to all downstream
|
|
1387
|
+
this.broadcastCompletion(agentId, causedBy);
|
|
1388
|
+
// notifyDownstreamForRevalidation removed — causes duplicate messages and re-execution loops
|
|
1389
|
+
// Downstream agents that already completed will be handled in future iteration mode
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
this.emitWorkspaceStatus();
|
|
1393
|
+
this.checkWorkspaceComplete?.();
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
private broadcastCompletion(completedAgentId: string, causedBy?: BusMessage['causedBy']): void {
|
|
1397
|
+
const completed = this.agents.get(completedAgentId);
|
|
1398
|
+
if (!completed) return;
|
|
1399
|
+
|
|
1400
|
+
const completedLabel = completed.config.label;
|
|
1401
|
+
const files = completed.state.artifacts.filter(a => a.path).map(a => a.path!);
|
|
1402
|
+
const summary = completed.state.history
|
|
1403
|
+
.filter(h => h.subtype === 'final_summary' || h.subtype === 'step_summary')
|
|
1404
|
+
.slice(-1)[0]?.content || '';
|
|
1405
|
+
|
|
1406
|
+
const content = files.length > 0
|
|
1407
|
+
? `${completedLabel} completed: ${files.length} files changed. ${summary.slice(0, 200)}`
|
|
1408
|
+
: `${completedLabel} completed. ${summary.slice(0, 300) || 'Check upstream outputs for updates.'}`;
|
|
1409
|
+
|
|
1410
|
+
// Find all downstream agents that depend on this one
|
|
1411
|
+
let sent = 0;
|
|
1412
|
+
for (const [id, entry] of this.agents) {
|
|
1413
|
+
if (id === completedAgentId) continue;
|
|
1414
|
+
if (entry.config.type === 'input') continue;
|
|
1415
|
+
if (!entry.config.dependsOn.includes(completedAgentId)) continue;
|
|
1416
|
+
|
|
1417
|
+
this.bus.send(completedAgentId, id, 'notify', {
|
|
1418
|
+
action: 'upstream_complete',
|
|
1419
|
+
content,
|
|
1420
|
+
files,
|
|
1421
|
+
}, { category: 'notification', causedBy });
|
|
1422
|
+
sent++;
|
|
1423
|
+
console.log(`[bus] ${completedLabel} → ${entry.config.label}: upstream_complete (${files.length} files)`);
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
if (sent === 0) {
|
|
1427
|
+
console.log(`[bus] ${completedLabel} completed — no downstream agents`);
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
// ─── Agent liveness ─────────────────────────────────────
|
|
1432
|
+
|
|
1433
|
+
private updateAgentLiveness(agentId: string): void {
|
|
1434
|
+
const entry = this.agents.get(agentId);
|
|
1435
|
+
if (!entry) {
|
|
1436
|
+
this.bus.setAgentStatus(agentId, 'down');
|
|
1437
|
+
return;
|
|
1438
|
+
}
|
|
1439
|
+
if (entry.state.taskStatus === 'running') this.bus.setAgentStatus(agentId, 'busy');
|
|
1440
|
+
else if (entry.state.smithStatus === 'active') this.bus.setAgentStatus(agentId, 'alive');
|
|
1441
|
+
else this.bus.setAgentStatus(agentId, 'down');
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
// ─── Bus message handling ──────────────────────────────
|
|
1445
|
+
|
|
1446
|
+
private handleBusMessage(msg: BusMessage): void {
|
|
1447
|
+
// Dedup
|
|
1448
|
+
if (this.bus.isDuplicate(msg.id)) return;
|
|
1449
|
+
|
|
1450
|
+
// Emit to UI after dedup (no duplicates, no ACKs)
|
|
1451
|
+
this.emit('event', { type: 'bus_message', message: msg } satisfies OrchestratorEvent);
|
|
1452
|
+
|
|
1453
|
+
// Route to target
|
|
1454
|
+
this.routeMessageToAgent(msg.to, msg);
|
|
1455
|
+
this.checkWorkspaceComplete();
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
private routeMessageToAgent(targetId: string, msg: BusMessage): void {
|
|
1459
|
+
const target = this.agents.get(targetId);
|
|
1460
|
+
if (!target) return;
|
|
1461
|
+
|
|
1462
|
+
const fromLabel = this.agents.get(msg.from)?.config.label || msg.from;
|
|
1463
|
+
const action = msg.payload.action;
|
|
1464
|
+
const content = msg.payload.content || '';
|
|
1465
|
+
|
|
1466
|
+
console.log(`[bus] ${fromLabel} → ${target.config.label}: ${action} "${content.slice(0, 80)}"`);
|
|
1467
|
+
|
|
1468
|
+
const logEntry = {
|
|
1469
|
+
type: 'system' as const,
|
|
1470
|
+
subtype: 'bus_message',
|
|
1471
|
+
content: `[From ${fromLabel}]: ${content || action}`,
|
|
1472
|
+
timestamp: new Date(msg.timestamp).toISOString(),
|
|
1473
|
+
};
|
|
1474
|
+
|
|
1475
|
+
// Helper: mark message as processed when actually consumed
|
|
1476
|
+
const ackAndDeliver = () => {
|
|
1477
|
+
msg.status = 'done';
|
|
1478
|
+
};
|
|
1479
|
+
|
|
1480
|
+
// ── Input node: request user input ──
|
|
1481
|
+
if (target.config.type === 'input') {
|
|
1482
|
+
if (action === 'info_request' || action === 'question') {
|
|
1483
|
+
ackAndDeliver();
|
|
1484
|
+
this.emit('event', {
|
|
1485
|
+
type: 'user_input_request',
|
|
1486
|
+
agentId: targetId,
|
|
1487
|
+
fromAgent: msg.from,
|
|
1488
|
+
question: content,
|
|
1489
|
+
} satisfies OrchestratorEvent);
|
|
1490
|
+
}
|
|
1491
|
+
return;
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
// ── Store message in agent history ──
|
|
1495
|
+
target.state.history.push(logEntry);
|
|
1496
|
+
|
|
1497
|
+
// ── Manual mode → store in inbox (user handles in terminal) ──
|
|
1498
|
+
if (target.state.mode === 'manual') {
|
|
1499
|
+
ackAndDeliver();
|
|
1500
|
+
console.log(`[bus] ${target.config.label}: received ${action} in manual mode — stored in inbox`);
|
|
1501
|
+
return;
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
// ── Message stays pending — message loop will consume it when smith is ready ──
|
|
1505
|
+
console.log(`[bus] ${target.config.label}: received ${action} — queued in inbox (${msg.status})`);
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
// ─── Message consumption loop ─────────────────────────
|
|
1509
|
+
private messageLoopTimers = new Map<string, NodeJS.Timeout>();
|
|
1510
|
+
|
|
1511
|
+
/** Start the message consumption loop for a smith */
|
|
1512
|
+
private startMessageLoop(agentId: string): void {
|
|
1513
|
+
if (this.messageLoopTimers.has(agentId)) return; // already running
|
|
1514
|
+
|
|
1515
|
+
let debugTick = 0;
|
|
1516
|
+
const tick = () => {
|
|
1517
|
+
const entry = this.agents.get(agentId);
|
|
1518
|
+
if (!entry || entry.state.smithStatus !== 'active') {
|
|
1519
|
+
this.stopMessageLoop(agentId);
|
|
1520
|
+
return;
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
// Skip if manual (user in terminal) or running (already busy)
|
|
1524
|
+
if (entry.state.mode === 'manual') return;
|
|
1525
|
+
if (entry.state.taskStatus === 'running') return;
|
|
1526
|
+
|
|
1527
|
+
// Skip if no worker ready
|
|
1528
|
+
if (!entry.worker?.isListening()) {
|
|
1529
|
+
if (++debugTick % 15 === 0) {
|
|
1530
|
+
console.log(`[inbox] ${entry.config.label}: not listening (worker=${!!entry.worker} smith=${entry.state.smithStatus} task=${entry.state.taskStatus})`);
|
|
1531
|
+
}
|
|
1532
|
+
return;
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
// Skip if worker is currently processing a message
|
|
1536
|
+
if (entry.worker?.getCurrentMessageId()) {
|
|
1537
|
+
const currentMsg = this.bus.getLog().find(m => m.id === entry.worker!.getCurrentMessageId());
|
|
1538
|
+
if (currentMsg && currentMsg.status === 'running') return;
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
// Find next pending message, applying causedBy rules
|
|
1542
|
+
const allPending = this.bus.getPendingMessagesFor(agentId).filter(m => m.from !== agentId && m.type !== 'ack');
|
|
1543
|
+
const pending = allPending.filter(m => {
|
|
1544
|
+
// Tickets: accepted but check retry limit
|
|
1545
|
+
if (m.category === 'ticket') {
|
|
1546
|
+
const maxRetries = m.maxRetries ?? 3;
|
|
1547
|
+
if ((m.ticketRetries || 0) >= maxRetries) {
|
|
1548
|
+
console.log(`[inbox] ${entry.config.label}: ticket ${m.id.slice(0, 8)} exceeded max retries (${maxRetries}), marking failed`);
|
|
1549
|
+
m.status = 'failed' as any;
|
|
1550
|
+
m.ticketStatus = 'closed';
|
|
1551
|
+
this.emit('event', { type: 'bus_message_status', messageId: m.id, status: 'failed' } as any);
|
|
1552
|
+
return false;
|
|
1553
|
+
}
|
|
1554
|
+
return true;
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
// Notifications: check causedBy for loop prevention
|
|
1558
|
+
if (m.causedBy) {
|
|
1559
|
+
// Rule 1: Is this a response to something I sent? → accept (for verification)
|
|
1560
|
+
const myOutbox = this.bus.getOutboxFor(agentId);
|
|
1561
|
+
if (myOutbox.some(o => o.id === m.causedBy!.messageId)) return true;
|
|
1562
|
+
|
|
1563
|
+
// Rule 2: Notification from downstream → discard (prevents reverse flow)
|
|
1564
|
+
if (!this.isUpstream(m.from, agentId)) {
|
|
1565
|
+
console.log(`[inbox] ${entry.config.label}: discarding notification from downstream ${this.agents.get(m.from)?.config.label || m.from}`);
|
|
1566
|
+
m.status = 'done' as any; // silently consume
|
|
1567
|
+
return false;
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
// Default: accept (upstream notifications, no causedBy = initial trigger)
|
|
1572
|
+
return true;
|
|
1573
|
+
});
|
|
1574
|
+
if (pending.length === 0) return;
|
|
1575
|
+
|
|
1576
|
+
const nextMsg = pending[0];
|
|
1577
|
+
const fromLabel = this.agents.get(nextMsg.from)?.config.label || nextMsg.from;
|
|
1578
|
+
console.log(`[inbox] ${entry.config.label}: consuming message from ${fromLabel} (${nextMsg.payload.action})`);
|
|
1579
|
+
|
|
1580
|
+
// Mark message as running (being processed)
|
|
1581
|
+
nextMsg.status = 'running' as any;
|
|
1582
|
+
this.emit('event', { type: 'bus_message_status', messageId: nextMsg.id, status: 'running' } as any);
|
|
1583
|
+
|
|
1584
|
+
const logEntry = {
|
|
1585
|
+
type: 'system' as const,
|
|
1586
|
+
subtype: 'bus_message',
|
|
1587
|
+
content: `[From ${fromLabel}]: ${nextMsg.payload.content || nextMsg.payload.action}`,
|
|
1588
|
+
timestamp: new Date(nextMsg.timestamp).toISOString(),
|
|
1589
|
+
};
|
|
1590
|
+
|
|
1591
|
+
entry.worker.setProcessingMessage(nextMsg.id);
|
|
1592
|
+
entry.worker.wake({ type: 'bus_message', messages: [logEntry] });
|
|
1593
|
+
};
|
|
1594
|
+
|
|
1595
|
+
// Check every 2 seconds
|
|
1596
|
+
const timer = setInterval(tick, 2000);
|
|
1597
|
+
timer.unref(); // Don't prevent process exit in tests
|
|
1598
|
+
this.messageLoopTimers.set(agentId, timer);
|
|
1599
|
+
// Also run immediately
|
|
1600
|
+
tick();
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
/** Stop the message consumption loop for a smith */
|
|
1604
|
+
private stopMessageLoop(agentId: string): void {
|
|
1605
|
+
const timer = this.messageLoopTimers.get(agentId);
|
|
1606
|
+
if (timer) {
|
|
1607
|
+
clearInterval(timer);
|
|
1608
|
+
this.messageLoopTimers.delete(agentId);
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
/** Stop all message loops */
|
|
1613
|
+
private stopAllMessageLoops(): void {
|
|
1614
|
+
for (const [id] of this.messageLoopTimers) {
|
|
1615
|
+
this.stopMessageLoop(id);
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
/** Check if all agents are done and no pending work remains */
|
|
1620
|
+
private checkWorkspaceComplete(): void {
|
|
1621
|
+
let allDone = true;
|
|
1622
|
+
for (const [id, entry] of this.agents) {
|
|
1623
|
+
const ws = entry.worker?.getState();
|
|
1624
|
+
const taskSt = ws?.taskStatus ?? entry.state.taskStatus;
|
|
1625
|
+
if (taskSt === 'running' || this.approvalQueue.has(id)) {
|
|
1626
|
+
allDone = false;
|
|
1627
|
+
break;
|
|
1628
|
+
}
|
|
1629
|
+
// idle agents with unmet deps don't block completion
|
|
1630
|
+
if (taskSt === 'idle' && entry.config.dependsOn.length > 0) {
|
|
1631
|
+
const allDepsDone = entry.config.dependsOn.every(depId => {
|
|
1632
|
+
const dep = this.agents.get(depId);
|
|
1633
|
+
return dep && (dep.state.taskStatus === 'done');
|
|
1634
|
+
});
|
|
1635
|
+
if (allDepsDone) {
|
|
1636
|
+
allDone = false; // idle but ready to run = not complete
|
|
1637
|
+
break;
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
if (allDone && this.agents.size > 0) {
|
|
1643
|
+
const hasPendingRequests = this.bus.getLog().some(m =>
|
|
1644
|
+
m.type === 'request' && !this.bus.getLog().some(r =>
|
|
1645
|
+
r.type === 'response' && r.payload.replyTo === m.id
|
|
1646
|
+
)
|
|
1647
|
+
);
|
|
1648
|
+
if (!hasPendingRequests) {
|
|
1649
|
+
console.log('[workspace] All agents complete, no pending requests. Workspace done.');
|
|
1650
|
+
this.emit('event', { type: 'workspace_complete' } satisfies OrchestratorEvent);
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
/** Get agents that are idle and have all dependencies met */
|
|
1656
|
+
private getReadyAgents(): string[] {
|
|
1657
|
+
const ready: string[] = [];
|
|
1658
|
+
for (const [id, entry] of this.agents) {
|
|
1659
|
+
if (entry.state.taskStatus !== 'idle') continue;
|
|
1660
|
+
const allDepsDone = entry.config.dependsOn.every(depId => {
|
|
1661
|
+
const dep = this.agents.get(depId);
|
|
1662
|
+
return dep && dep.state.taskStatus === 'done';
|
|
1663
|
+
});
|
|
1664
|
+
if (allDepsDone) ready.push(id);
|
|
1665
|
+
}
|
|
1666
|
+
return ready;
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
/**
|
|
1670
|
+
* Parse CLI agent output for bus message markers.
|
|
1671
|
+
* Format: [SEND:TargetLabel:action] content
|
|
1672
|
+
* Example: [SEND:Engineer:fix_request] SQL injection found in auth module
|
|
1673
|
+
*/
|
|
1674
|
+
/**
|
|
1675
|
+
* After an agent completes, notify downstream agents that already ran (done/failed)
|
|
1676
|
+
* to re-validate their work. Sets them to waiting_approval so user decides.
|
|
1677
|
+
*/
|
|
1678
|
+
private notifyDownstreamForRevalidation(completedAgentId: string, files: string[]): void {
|
|
1679
|
+
const completedLabel = this.agents.get(completedAgentId)?.config.label || completedAgentId;
|
|
1680
|
+
|
|
1681
|
+
for (const [id, entry] of this.agents) {
|
|
1682
|
+
if (id === completedAgentId) continue;
|
|
1683
|
+
if (!entry.config.dependsOn.includes(completedAgentId)) continue;
|
|
1684
|
+
|
|
1685
|
+
// Only notify agents that already completed — they need to re-validate
|
|
1686
|
+
if (entry.state.taskStatus !== 'done' && entry.state.taskStatus !== 'failed') continue;
|
|
1687
|
+
|
|
1688
|
+
console.log(`[workspace] ${completedLabel} changed → ${entry.config.label} needs re-validation`);
|
|
1689
|
+
|
|
1690
|
+
// Send bus message
|
|
1691
|
+
this.bus.send(completedAgentId, id, 'notify', {
|
|
1692
|
+
action: 'update_notify',
|
|
1693
|
+
content: `${completedLabel} completed with changes. Please re-validate.`,
|
|
1694
|
+
files,
|
|
1695
|
+
});
|
|
1696
|
+
|
|
1697
|
+
// Set to waiting_approval so user confirms re-run
|
|
1698
|
+
entry.state.taskStatus = 'idle';
|
|
1699
|
+
entry.state.history.push({
|
|
1700
|
+
type: 'system',
|
|
1701
|
+
subtype: 'revalidation_request',
|
|
1702
|
+
content: `[${completedLabel}] completed with changes — approve to re-run validation`,
|
|
1703
|
+
timestamp: new Date().toISOString(),
|
|
1704
|
+
});
|
|
1705
|
+
this.approvalQueue.add(id);
|
|
1706
|
+
this.emit('event', { type: 'task_status', agentId: id, taskStatus: 'idle' } satisfies WorkerEvent);
|
|
1707
|
+
this.emit('event', {
|
|
1708
|
+
type: 'approval_required',
|
|
1709
|
+
agentId: id,
|
|
1710
|
+
upstreamId: completedAgentId,
|
|
1711
|
+
} satisfies OrchestratorEvent);
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
/** Track how many history entries have been scanned per agent to avoid re-parsing */
|
|
1716
|
+
private busMarkerScanned = new Map<string, number>();
|
|
1717
|
+
|
|
1718
|
+
private parseBusMarkers(fromAgentId: string, history: { type: string; content: string }[]): void {
|
|
1719
|
+
const markerRegex = /\[SEND:([^:]+):([^\]]+)\]\s*(.+)/g;
|
|
1720
|
+
const labelToId = new Map<string, string>();
|
|
1721
|
+
for (const [id, e] of this.agents) {
|
|
1722
|
+
labelToId.set(e.config.label.toLowerCase(), id);
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
// Only scan new entries since last parse (avoid re-sending from old history)
|
|
1726
|
+
const lastScanned = this.busMarkerScanned.get(fromAgentId) || 0;
|
|
1727
|
+
const newEntries = history.slice(lastScanned);
|
|
1728
|
+
this.busMarkerScanned.set(fromAgentId, history.length);
|
|
1729
|
+
|
|
1730
|
+
for (const entry of newEntries) {
|
|
1731
|
+
let match;
|
|
1732
|
+
while ((match = markerRegex.exec(entry.content)) !== null) {
|
|
1733
|
+
const targetLabel = match[1].trim();
|
|
1734
|
+
const action = match[2].trim();
|
|
1735
|
+
const content = match[3].trim();
|
|
1736
|
+
const targetId = labelToId.get(targetLabel.toLowerCase());
|
|
1737
|
+
|
|
1738
|
+
if (targetId && targetId !== fromAgentId) {
|
|
1739
|
+
console.log(`[bus] Parsed marker from ${fromAgentId}: → ${targetLabel} (${action}): ${content.slice(0, 60)}`);
|
|
1740
|
+
this.bus.send(fromAgentId, targetId, 'notify', { action, content });
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
private saveNow(): void {
|
|
1747
|
+
saveWorkspace(this.getFullState()).catch(() => {});
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
/** Emit agents_changed so SSE pushes the updated list to frontend */
|
|
1751
|
+
private emitAgentsChanged(): void {
|
|
1752
|
+
const agents = Array.from(this.agents.values()).map(e => e.config);
|
|
1753
|
+
const agentStates = this.getAllAgentStates();
|
|
1754
|
+
this.emit('event', { type: 'agents_changed', agents, agentStates } satisfies WorkerEvent);
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
private emitWorkspaceStatus(): void {
|
|
1758
|
+
let running = 0, done = 0;
|
|
1759
|
+
for (const [, entry] of this.agents) {
|
|
1760
|
+
const ws = entry.worker?.getState();
|
|
1761
|
+
const taskSt = ws?.taskStatus ?? entry.state.taskStatus;
|
|
1762
|
+
if (taskSt === 'running') running++;
|
|
1763
|
+
if (taskSt === 'done') done++;
|
|
1764
|
+
}
|
|
1765
|
+
this.emit('event', {
|
|
1766
|
+
type: 'workspace_status',
|
|
1767
|
+
running,
|
|
1768
|
+
done,
|
|
1769
|
+
total: this.agents.size,
|
|
1770
|
+
} satisfies OrchestratorEvent);
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
/**
|
|
1774
|
+
* Update agent memory after execution completes.
|
|
1775
|
+
* Parses step results into structured memory entries.
|
|
1776
|
+
*/
|
|
1777
|
+
private async updateAgentMemory(agentId: string, config: WorkspaceAgentConfig, stepResults: string[]): Promise<void> {
|
|
1778
|
+
try {
|
|
1779
|
+
const entry = this.agents.get(agentId);
|
|
1780
|
+
|
|
1781
|
+
// Capture observation from the last step (previous steps captured in 'step' event handler)
|
|
1782
|
+
const lastStep = config.steps[config.steps.length - 1];
|
|
1783
|
+
const lastResult = stepResults[stepResults.length - 1];
|
|
1784
|
+
if (lastStep && lastResult) {
|
|
1785
|
+
const obs = parseStepToObservations(lastStep.label, lastResult, entry?.state.artifacts || []);
|
|
1786
|
+
for (const o of obs) {
|
|
1787
|
+
await addObservation(this.workspaceId, agentId, config.label, config.role, o);
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
// Add session summary
|
|
1792
|
+
const summary = buildSessionSummary(
|
|
1793
|
+
config.steps.map(s => s.label),
|
|
1794
|
+
stepResults,
|
|
1795
|
+
entry?.state.artifacts || [],
|
|
1796
|
+
);
|
|
1797
|
+
await addSessionSummary(this.workspaceId, agentId, summary);
|
|
1798
|
+
|
|
1799
|
+
console.log(`[workspace] Updated memory for ${config.label}`);
|
|
1800
|
+
} catch (err: any) {
|
|
1801
|
+
console.error(`[workspace] Failed to update memory for ${config.label}:`, err.message);
|
|
1802
|
+
}
|
|
1803
|
+
}
|
|
1804
|
+
}
|