@buihongduc132/pi-acp-agents 0.3.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/LICENSE +21 -0
  3. package/README.md +359 -0
  4. package/index.ts +1521 -0
  5. package/package.json +103 -0
  6. package/skills/pi-acp-agents/SKILL.md +112 -0
  7. package/src/acp-widget.ts +379 -0
  8. package/src/adapter-factory.ts +55 -0
  9. package/src/adapters/acpx.ts +215 -0
  10. package/src/adapters/base.ts +117 -0
  11. package/src/adapters/codex.ts +77 -0
  12. package/src/adapters/custom.ts +14 -0
  13. package/src/adapters/gemini.ts +66 -0
  14. package/src/adapters/opencode.ts +101 -0
  15. package/src/config/config.ts +312 -0
  16. package/src/config/types.ts +203 -0
  17. package/src/coordination/alias-resolver.ts +208 -0
  18. package/src/coordination/coordinator.ts +266 -0
  19. package/src/coordination/worker-dispatcher.ts +191 -0
  20. package/src/core/async-executor.ts +149 -0
  21. package/src/core/circuit-breaker.ts +254 -0
  22. package/src/core/client.ts +661 -0
  23. package/src/core/health-monitor.ts +200 -0
  24. package/src/core/protocol-validator.ts +259 -0
  25. package/src/core/session-lifecycle.ts +46 -0
  26. package/src/core/session-manager.ts +64 -0
  27. package/src/extension-safety.ts +200 -0
  28. package/src/logger.ts +92 -0
  29. package/src/management/event-log.ts +31 -0
  30. package/src/management/governance-store.ts +123 -0
  31. package/src/management/heartbeat-parser.ts +92 -0
  32. package/src/management/mailbox-manager.ts +95 -0
  33. package/src/management/runtime-paths.ts +34 -0
  34. package/src/management/safe-mkdir.ts +78 -0
  35. package/src/management/session-archive-store.ts +136 -0
  36. package/src/management/session-name-store.ts +88 -0
  37. package/src/management/task-store.ts +260 -0
  38. package/src/management/worker-store.ts +164 -0
  39. package/src/public-api.ts +72 -0
  40. package/src/settings/agent-config-tui.ts +456 -0
  41. package/src/settings/agents-command.ts +138 -0
  42. package/src/settings/config.ts +201 -0
  43. package/src/settings/configure-tui.ts +135 -0
@@ -0,0 +1,208 @@
1
+ /**
2
+ * pi-acp-agents — Alias resolver with fallback chains.
3
+ *
4
+ * Resolves an alias name to a concrete agent using configurable
5
+ * strategies: failover (sequential) or race (parallel, first wins).
6
+ */
7
+ import type { AcpAliasConfig, AcpPromptResult } from "../config/types.js";
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Errors
11
+ // ---------------------------------------------------------------------------
12
+
13
+ export class AllAgentsFailedError extends Error {
14
+ constructor(
15
+ public readonly attempts: Array<{ agent: string; error: Error }>,
16
+ aliasName: string,
17
+ ) {
18
+ super(
19
+ `All agents failed for alias "${aliasName}": ${attempts.map((a) => a.agent).join(", ")}`,
20
+ );
21
+ this.name = "AllAgentsFailedError";
22
+ }
23
+ }
24
+
25
+ export class NoHealthyAgentsError extends Error {
26
+ constructor(aliasName: string) {
27
+ super(`No healthy agents for alias "${aliasName}"`);
28
+ this.name = "NoHealthyAgentsError";
29
+ }
30
+ }
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Types
34
+ // ---------------------------------------------------------------------------
35
+
36
+ export type DelegateFn = (
37
+ agentName: string,
38
+ message: string,
39
+ cwd?: string,
40
+ ) => Promise<AcpPromptResult>;
41
+
42
+ export type IsHealthyFn = (agentName: string) => boolean;
43
+
44
+ /** Called to cancel an in-flight agent request by name */
45
+ export type CancelFn = (agentName: string) => void;
46
+
47
+ /** Optional config for race strategy */
48
+ export interface AliasResolverConfig {
49
+ raceTimeoutMs?: number;
50
+ }
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // AliasResolver
54
+ // ---------------------------------------------------------------------------
55
+
56
+ export class AliasResolver {
57
+ private readonly raceTimeoutMs: number;
58
+
59
+ constructor(
60
+ private readonly aliases: Record<string, AcpAliasConfig>,
61
+ private readonly delegateFn: DelegateFn,
62
+ private readonly isHealthyFn: IsHealthyFn,
63
+ private readonly cancelFn?: CancelFn,
64
+ config?: AliasResolverConfig,
65
+ ) {
66
+ this.raceTimeoutMs = config?.raceTimeoutMs ?? 30_000;
67
+ }
68
+
69
+ /**
70
+ * Resolve an alias to a concrete agent result.
71
+ *
72
+ * @throws Error if alias not found or has no agents
73
+ * @throws NoHealthyAgentsError if all agents are unhealthy (circuit open)
74
+ * @throws AllAgentsFailedError if all agents in the chain fail
75
+ */
76
+ async resolve(
77
+ aliasName: string,
78
+ message: string,
79
+ cwd?: string,
80
+ ): Promise<AcpPromptResult> {
81
+ const alias = this.aliases[aliasName];
82
+ if (!alias) throw new Error(`Alias "${aliasName}" not found`);
83
+ if (!alias.agents || alias.agents.length === 0)
84
+ throw new Error(`Alias "${aliasName}" has no agents`);
85
+
86
+ // Filter healthy agents based on circuit breaker state
87
+ const healthyAgents = alias.agents.filter((name) =>
88
+ this.isHealthyFn(name),
89
+ );
90
+
91
+ if (alias.strategy === "race") {
92
+ return this.race(aliasName, healthyAgents, message, cwd);
93
+ }
94
+ return this.failover(aliasName, healthyAgents, message, cwd);
95
+ }
96
+
97
+ // -------------------------------------------------------------------------
98
+ // Failover: try agents sequentially, return on first success
99
+ // -------------------------------------------------------------------------
100
+
101
+ private async failover(
102
+ aliasName: string,
103
+ agents: string[],
104
+ message: string,
105
+ cwd?: string,
106
+ ): Promise<AcpPromptResult> {
107
+ if (agents.length === 0) throw new NoHealthyAgentsError(aliasName);
108
+
109
+ const attempts: Array<{ agent: string; error: Error }> = [];
110
+
111
+ for (const agent of agents) {
112
+ try {
113
+ return await this.delegateFn(agent, message, cwd);
114
+ } catch (err) {
115
+ attempts.push({
116
+ agent,
117
+ error: err instanceof Error ? err : new Error(String(err)),
118
+ });
119
+ }
120
+ }
121
+
122
+ throw new AllAgentsFailedError(attempts, aliasName);
123
+ }
124
+
125
+ // -------------------------------------------------------------------------
126
+ // Race: dispatch to all healthy agents in parallel, first response wins.
127
+ // EC-1: Timeout guard — rejects if no agent responds within raceTimeoutMs.
128
+ // EC-2: Loser cancellation — aborts all in-flight delegates when one wins.
129
+ // -------------------------------------------------------------------------
130
+
131
+ private async race(
132
+ aliasName: string,
133
+ agents: string[],
134
+ message: string,
135
+ cwd?: string,
136
+ ): Promise<AcpPromptResult> {
137
+ if (agents.length === 0) throw new NoHealthyAgentsError(aliasName);
138
+
139
+ const abortControllers = new Map<string, AbortController>();
140
+ const attempts: Array<{ agent: string; error: Error }> = [];
141
+
142
+ return new Promise<AcpPromptResult>((resolve, reject) => {
143
+ let settled = false;
144
+ let failureCount = 0;
145
+ const pending = new Set(agents);
146
+
147
+ const settle = (result: AcpPromptResult, winnerName: string) => {
148
+ if (!settled) {
149
+ settled = true;
150
+ // EC-2: Cancel all losing agents
151
+ for (const [name, ctrl] of abortControllers) {
152
+ if (pending.has(name)) {
153
+ ctrl.abort();
154
+ this.cancelFn?.(name);
155
+ }
156
+ }
157
+ resolve(result);
158
+ }
159
+ };
160
+
161
+ // EC-1: Timeout guard
162
+ const timer = setTimeout(() => {
163
+ if (!settled) {
164
+ settled = true;
165
+ // Cancel all in-flight delegates
166
+ for (const name of pending) {
167
+ const ctrl = abortControllers.get(name);
168
+ if (ctrl) ctrl.abort();
169
+ this.cancelFn?.(name);
170
+ }
171
+ reject(
172
+ new Error(
173
+ `Race timeout for alias "${aliasName}": no agent responded within ${this.raceTimeoutMs}ms`,
174
+ ),
175
+ );
176
+ }
177
+ }, this.raceTimeoutMs);
178
+
179
+ for (const agent of agents) {
180
+ const controller = new AbortController();
181
+ abortControllers.set(agent, controller);
182
+
183
+ this.delegateFn(agent, message, cwd)
184
+ .then((result) => {
185
+ pending.delete(agent);
186
+ settle(result, agent);
187
+ })
188
+ .catch((err) => {
189
+ pending.delete(agent);
190
+ if (!settled) {
191
+ attempts.push({
192
+ agent,
193
+ error: err instanceof Error
194
+ ? err
195
+ : new Error(String(err)),
196
+ });
197
+ failureCount++;
198
+ if (failureCount === agents.length) {
199
+ clearTimeout(timer);
200
+ settled = true;
201
+ reject(new AllAgentsFailedError(attempts, aliasName));
202
+ }
203
+ }
204
+ });
205
+ }
206
+ });
207
+ }
208
+ }
@@ -0,0 +1,266 @@
1
+ /**
2
+ * pi-acp-agents — Agent Coordinator for Level 3 multi-agent operations.
3
+ *
4
+ * Provides delegate, broadcast, compare, and formatComparison operations
5
+ * across multiple ACP agents. Each operation creates isolated adapter
6
+ * instances that are disposed after use.
7
+ */
8
+ import { createAdapter } from "../adapter-factory.js";
9
+ import { AliasResolver } from "./alias-resolver.js";
10
+ import type { AcpConfig, AcpPromptResult } from "../config/types.js";
11
+
12
+ /** Wrap a promise with a timeout. Throws on expiry with descriptive message. */
13
+ function withTimeoutMs<T>(promise: Promise<T>, ms: number | undefined, label: string): Promise<T> {
14
+ const effectiveMs = ms ?? 300_000;
15
+ if (effectiveMs <= 0) return promise;
16
+ let timer: ReturnType<typeof setTimeout>;
17
+ return Promise.race([
18
+ promise,
19
+ new Promise<never>((_, reject) => {
20
+ timer = setTimeout(() => reject(new Error(`${label} timed out after ${effectiveMs}ms`)), effectiveMs);
21
+ }),
22
+ ]).finally(() => clearTimeout(timer));
23
+ }
24
+
25
+ /** Progress update during delegation */
26
+ export interface AcpDelegateProgress {
27
+ agentName: string;
28
+ phase: "spawning" | "initializing" | "prompting" | "done" | "error";
29
+ durationMs?: number;
30
+ lastActivityAt?: number;
31
+ text?: string;
32
+ }
33
+
34
+ /** Flat result from a single agent in a multi-agent operation */
35
+ export interface AgentResult {
36
+ agent: string;
37
+ text: string;
38
+ sessionId: string;
39
+ stopReason: string;
40
+ error?: string;
41
+ }
42
+
43
+ /** Comparison result from multiple agents */
44
+ export interface ComparisonResult {
45
+ prompt: string;
46
+ responses: AgentResult[];
47
+ timestamp: string;
48
+ }
49
+
50
+ export interface AgentCoordinatorDeps {
51
+ /** Check if a specific agent's circuit breaker is healthy */
52
+ isHealthyFn?: (agentName: string) => boolean;
53
+ /** Record success/failure for circuit breaker tracking */
54
+ recordSuccessFn?: (agentName: string) => void;
55
+ recordFailureFn?: (agentName: string) => void;
56
+ }
57
+
58
+ export class AgentCoordinator {
59
+ private isHealthyFn: (agentName: string) => boolean;
60
+ private recordSuccessFn?: (agentName: string) => void;
61
+ private recordFailureFn?: (agentName: string) => void;
62
+
63
+ constructor(
64
+ private config: AcpConfig,
65
+ private cwd: string,
66
+ deps?: AgentCoordinatorDeps,
67
+ ) {
68
+ this.isHealthyFn = deps?.isHealthyFn ?? (() => true);
69
+ this.recordSuccessFn = deps?.recordSuccessFn;
70
+ this.recordFailureFn = deps?.recordFailureFn;
71
+ }
72
+
73
+ /** Delegate a task to a single agent or alias. Creates a short-lived session. */
74
+ async delegate(
75
+ agentName: string,
76
+ message: string,
77
+ cwd?: string,
78
+ onProgress?: (progress: AcpDelegateProgress) => void,
79
+ signal?: AbortSignal,
80
+ ): Promise<AcpPromptResult> {
81
+ // Pre-aborted check
82
+ if (signal?.aborted) {
83
+ const adapter = this.createAdapterForAgent(agentName, cwd);
84
+ try {
85
+ adapter.cancel();
86
+ } catch { /* best-effort */ }
87
+ try {
88
+ adapter.dispose();
89
+ } catch { /* best-effort — dispose must not throw */ }
90
+ const err = new DOMException("Operation cancelled", "AbortError");
91
+ onProgress?.({ agentName, phase: "error" });
92
+ throw err;
93
+ }
94
+
95
+ // Check if it's an alias first
96
+ const aliasConfig = this.config.agent_aliases?.[agentName];
97
+ if (aliasConfig) {
98
+ const isHealthy = this.isHealthyFn;
99
+ const recordSuccess = this.recordSuccessFn;
100
+ const recordFailure = this.recordFailureFn;
101
+ const resolver = new AliasResolver(
102
+ { [agentName]: aliasConfig },
103
+ async (name, msg, c) => {
104
+ try {
105
+ const result = await this.delegateToAgent(name, msg, c, onProgress, signal);
106
+ recordSuccess?.(name);
107
+ return result;
108
+ } catch (err) {
109
+ recordFailure?.(name);
110
+ throw err;
111
+ }
112
+ },
113
+ (name) => isHealthy(name),
114
+ undefined,
115
+ this.config.raceTimeoutMs ? { raceTimeoutMs: this.config.raceTimeoutMs } : undefined,
116
+ );
117
+ return resolver.resolve(agentName, message, cwd);
118
+ }
119
+
120
+ return this.delegateToAgent(agentName, message, cwd, onProgress, signal);
121
+ }
122
+
123
+ /** Create an adapter for a concrete agent. */
124
+ private createAdapterForAgent(agentName: string, cwd?: string) {
125
+ const agentCfg = this.config.agent_servers[agentName];
126
+ if (!agentCfg) throw new Error(`Agent "${agentName}" not found`);
127
+ const effectiveCwd = cwd ?? this.cwd;
128
+ return createAdapter(agentName, agentCfg, this.config, effectiveCwd);
129
+ }
130
+
131
+ /** Delegate directly to a concrete agent. Creates a short-lived session. */
132
+ private async delegateToAgent(
133
+ agentName: string,
134
+ message: string,
135
+ cwd?: string,
136
+ onProgress?: (progress: AcpDelegateProgress) => void,
137
+ signal?: AbortSignal,
138
+ ): Promise<AcpPromptResult> {
139
+ const agentCfg = this.config.agent_servers[agentName];
140
+ if (!agentCfg) throw new Error(`Agent "${agentName}" not found`);
141
+
142
+ const effectiveCwd = cwd ?? this.cwd;
143
+ const adapter = createAdapter(agentName, agentCfg, this.config, effectiveCwd);
144
+ const startTime = Date.now();
145
+
146
+ const emitProgress = (phase: AcpDelegateProgress["phase"]) => {
147
+ onProgress?.({
148
+ agentName,
149
+ phase,
150
+ durationMs: Date.now() - startTime,
151
+ lastActivityAt: Date.now(),
152
+ });
153
+ };
154
+
155
+ // Abort handler: cancel + dispose + reject pending prompt
156
+ let abortReject: ((err: Error) => void) | null = null;
157
+ const abortPromise = new Promise<never>((_, reject) => {
158
+ abortReject = reject;
159
+ });
160
+ // Safety: prevent unhandled rejection if abort fires after race settles
161
+ abortPromise.catch(() => {});
162
+
163
+ const onAbort = () => {
164
+ try { adapter.cancel(); } catch { /* best-effort */ }
165
+ try { adapter.dispose(); } catch { /* best-effort — dispose must not throw */ }
166
+ emitProgress("error");
167
+ abortReject?.(new DOMException("Operation cancelled", "AbortError"));
168
+ };
169
+
170
+ signal?.addEventListener("abort", onAbort, { once: true });
171
+
172
+ try {
173
+ emitProgress("spawning");
174
+ await withTimeoutMs(adapter.spawn(), this.config.stallTimeoutMs, `acp_spawn(delegate:${agentName})`);
175
+ emitProgress("initializing");
176
+ await adapter.initialize();
177
+ await adapter.newSession(effectiveCwd);
178
+ emitProgress("prompting");
179
+ const promptPromise = adapter.prompt(message);
180
+ // Prevent unhandled rejection if abort wins the race
181
+ promptPromise.catch(() => {});
182
+ return await Promise.race([promptPromise, abortPromise]);
183
+ } finally {
184
+ signal?.removeEventListener("abort", onAbort);
185
+ try { adapter.dispose(); } catch { /* best-effort — dispose must not mask errors */ }
186
+ }
187
+ }
188
+
189
+ /** Broadcast the same prompt to multiple agents in parallel. */
190
+ async broadcast(
191
+ agentNames: string[],
192
+ message: string,
193
+ cwd?: string,
194
+ ): Promise<AgentResult[]> {
195
+ const results = await Promise.allSettled(
196
+ agentNames.map(async (name): Promise<AgentResult> => {
197
+ try {
198
+ const result = await this.delegate(name, message, cwd);
199
+ return {
200
+ agent: name,
201
+ text: result.text,
202
+ sessionId: result.sessionId,
203
+ stopReason: result.stopReason,
204
+ };
205
+ } catch (err: unknown) {
206
+ const msg = err instanceof Error ? err.message : String(err);
207
+ return {
208
+ agent: name,
209
+ text: "",
210
+ sessionId: "",
211
+ stopReason: "error",
212
+ error: msg,
213
+ };
214
+ }
215
+ }),
216
+ );
217
+
218
+ return results.map((r) =>
219
+ r.status === "fulfilled"
220
+ ? r.value
221
+ : { agent: "unknown", text: "", sessionId: "", stopReason: "error", error: String(r.reason) },
222
+ );
223
+ }
224
+
225
+ /** Get responses from multiple agents and return structured comparison. */
226
+ async compare(
227
+ agentNames: string[],
228
+ message: string,
229
+ cwd?: string,
230
+ ): Promise<ComparisonResult> {
231
+ const responses = await this.broadcast(agentNames, message, cwd);
232
+ return {
233
+ prompt: message,
234
+ responses,
235
+ timestamp: new Date().toISOString(),
236
+ };
237
+ }
238
+
239
+ /** Format a comparison result as readable text */
240
+ formatComparison(comparison: {
241
+ prompt: string;
242
+ responses: Array<{
243
+ agent: string;
244
+ text?: string;
245
+ sessionId?: string;
246
+ stopReason?: string;
247
+ error?: string;
248
+ }>;
249
+ timestamp: string;
250
+ }): string {
251
+ const lines = comparison.responses.map((r) => {
252
+ if (r.error) {
253
+ return ` ${r.agent}: (ERROR) ${r.error}`;
254
+ }
255
+ return ` ${r.agent}: ${r.text ?? "(no response)"}`;
256
+ });
257
+
258
+ return (
259
+ `ACP Agent Comparison\n` +
260
+ `────────────────────\n` +
261
+ `Prompt: ${comparison.prompt}\n` +
262
+ `Time: ${comparison.timestamp}\n\n` +
263
+ lines.join("\n")
264
+ );
265
+ }
266
+ }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * pi-acp-agents — Worker Dispatcher (M6: Auto-Claim Dispatch)
3
+ *
4
+ * Background auto-claim loop that dispatches tasks to idle workers.
5
+ * Uses round-robin/FIFO selection when multiple idle workers compete for tasks.
6
+ * Respects SessionManager busy mutex — skips workers that are already busy.
7
+ */
8
+ import type { AcpWorkerRecord } from "../config/types.js";
9
+
10
+ export interface WorkerDispatcherDeps {
11
+ workerStore: {
12
+ list(options?: { status?: string }): AcpWorkerRecord[];
13
+ updateStatus(name: string, status: "idle" | "busy" | "online" | "offline"): AcpWorkerRecord;
14
+ assignTask(name: string, taskId: string): void;
15
+ unassignTask(name: string): AcpWorkerRecord;
16
+ get(name: string): AcpWorkerRecord | undefined;
17
+ updateMetadata(name: string, metadata: Partial<Record<string, unknown>>): AcpWorkerRecord | undefined;
18
+ };
19
+ taskStore: {
20
+ claimNextAvailable(): Promise<{ id: string; subject: string; description: string | null } | null>;
21
+ update(id: string, mut: (task: { status: string; result: string | null }) => void): Promise<any>;
22
+ };
23
+ eventLog: {
24
+ append(event: string, data: Record<string, unknown>): void;
25
+ };
26
+ busySessions: Map<string, boolean>;
27
+ dispatchTask: (sessionId: string, prompt: string) => Promise<{ ok: boolean; value?: any; error?: string }>;
28
+ getSessionIdForWorker: (workerName: string) => string | undefined;
29
+ }
30
+
31
+ export class WorkerDispatcher {
32
+ private intervalId: ReturnType<typeof setInterval> | null = null;
33
+ private dispatchIndex = 0; // Round-robin index for FIFO worker selection
34
+ private running = false;
35
+
36
+ constructor(
37
+ private deps: WorkerDispatcherDeps,
38
+ private intervalMs: number = 5000,
39
+ ) {}
40
+
41
+ /** Start the auto-claim dispatch loop */
42
+ start(): void {
43
+ if (this.running) return;
44
+ this.running = true;
45
+ this.intervalId = setInterval(() => {
46
+ this.dispatchOnce().catch((err) => {
47
+ this.deps.eventLog.append("dispatch_error", {
48
+ error: err instanceof Error ? err.message : String(err),
49
+ });
50
+ });
51
+ }, this.intervalMs);
52
+ }
53
+
54
+ /** Stop the auto-claim dispatch loop */
55
+ stop(): void {
56
+ if (this.intervalId) {
57
+ clearInterval(this.intervalId);
58
+ this.intervalId = null;
59
+ }
60
+ this.running = false;
61
+ }
62
+
63
+ get isRunning(): boolean {
64
+ return this.running;
65
+ }
66
+
67
+ /**
68
+ * Single dispatch cycle: iterate idle workers in round-robin order,
69
+ * attempt to claim a task for each, and dispatch.
70
+ */
71
+ async dispatchOnce(): Promise<void> {
72
+ const idleWorkers = this.deps.workerStore.list({ status: "idle" });
73
+ if (idleWorkers.length === 0) return;
74
+
75
+ // Round-robin: start from dispatchIndex, wrap around
76
+ for (let i = 0; i < idleWorkers.length; i++) {
77
+ const workerIdx = (this.dispatchIndex + i) % idleWorkers.length;
78
+ const worker = idleWorkers[workerIdx]!;
79
+
80
+ // Respect busy mutex — skip if session is in-flight
81
+ const sessionId = this.deps.getSessionIdForWorker(worker.name);
82
+ if (sessionId && this.deps.busySessions.get(sessionId)) {
83
+ continue;
84
+ }
85
+
86
+ // Try to claim a task
87
+ const task = await this.deps.taskStore.claimNextAvailable();
88
+ if (!task) continue;
89
+
90
+ // Build task prompt, prepending any queued steer message
91
+ let prompt = this.buildTaskPrompt(task);
92
+ const workerRecord = this.deps.workerStore.get(worker.name);
93
+ if (workerRecord?.metadata?.pendingSteer) {
94
+ prompt = String(workerRecord.metadata.pendingSteer) + "\n\n" + prompt;
95
+ this.deps.workerStore.updateMetadata(worker.name, { pendingSteer: undefined });
96
+ }
97
+
98
+ // Mark worker busy and assign task
99
+ this.deps.workerStore.updateStatus(worker.name, "busy");
100
+ this.deps.workerStore.assignTask(worker.name, task.id);
101
+
102
+ // Emit task_assigned event
103
+ this.deps.eventLog.append("task_assigned", {
104
+ workerName: worker.name,
105
+ taskId: task.id,
106
+ sessionId,
107
+ });
108
+
109
+ // Advance dispatch index (next worker gets priority)
110
+ this.dispatchIndex = workerIdx + 1;
111
+
112
+ // Dispatch and handle completion
113
+ if (sessionId) {
114
+ // Fire-and-forget: dispatch the task, handle completion asynchronously
115
+ this.handleDispatchResult(worker.name, sessionId, task.id, this.deps.dispatchTask(sessionId, prompt));
116
+ }
117
+
118
+ // Only dispatch one task per cycle to avoid overwhelming
119
+ return;
120
+ }
121
+ }
122
+
123
+ private buildTaskPrompt(task: { id: string; subject: string; description: string | null }): string {
124
+ let prompt = `## Task: ${task.subject}\nTask ID: ${task.id}\n`;
125
+ if (task.description) {
126
+ prompt += `\n${task.description}\n`;
127
+ }
128
+ prompt += `\nComplete this task. Report the result when done.`;
129
+ return prompt;
130
+ }
131
+
132
+ private async handleDispatchResult(
133
+ workerName: string,
134
+ sessionId: string,
135
+ taskId: string,
136
+ resultPromise: Promise<{ ok: boolean; value?: any; error?: string }>,
137
+ ): Promise<void> {
138
+ try {
139
+ const result = await resultPromise;
140
+
141
+ if (result.ok) {
142
+ // Mark task completed
143
+ await this.deps.taskStore.update(taskId, (task) => {
144
+ task.status = "completed";
145
+ task.result = typeof result.value === "string" ? result.value : JSON.stringify(result.value);
146
+ });
147
+
148
+ // Emit task_completed event
149
+ this.deps.eventLog.append("task_completed", {
150
+ workerName,
151
+ taskId,
152
+ sessionId,
153
+ });
154
+ } else {
155
+ // Task failed — set back to pending
156
+ await this.deps.taskStore.update(taskId, (task) => {
157
+ task.status = "pending";
158
+ task.result = null;
159
+ });
160
+
161
+ this.deps.eventLog.append("task_dispatch_failed", {
162
+ workerName,
163
+ taskId,
164
+ sessionId,
165
+ error: result.error,
166
+ });
167
+ }
168
+ } catch (err) {
169
+ // Unexpected error — set task back to pending
170
+ try {
171
+ await this.deps.taskStore.update(taskId, (task) => {
172
+ task.status = "pending";
173
+ task.result = null;
174
+ });
175
+ } catch {
176
+ // Ignore update errors
177
+ }
178
+
179
+ this.deps.eventLog.append("task_dispatch_error", {
180
+ workerName,
181
+ taskId,
182
+ sessionId,
183
+ error: err instanceof Error ? err.message : String(err),
184
+ });
185
+ } finally {
186
+ // Always return worker to idle and unassign task
187
+ this.deps.workerStore.updateStatus(workerName, "idle");
188
+ this.deps.workerStore.unassignTask(workerName);
189
+ }
190
+ }
191
+ }