@teamclaws/teamclaw 2026.3.21

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.
@@ -0,0 +1,1946 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import http from "node:http";
4
+ import type { IncomingMessage, ServerResponse } from "node:http";
5
+ import type { OpenClawPluginApi, PluginLogger } from "../../api.js";
6
+ import type {
7
+ ClarificationRequest,
8
+ GitRepoState,
9
+ PluginConfig,
10
+ RepoSyncInfo,
11
+ RoleId,
12
+ TaskExecution,
13
+ TaskExecutionEvent,
14
+ TaskExecutionEventInput,
15
+ TaskAssignmentPayload,
16
+ TaskExecutionSummary,
17
+ TaskInfo,
18
+ TaskPriority,
19
+ TaskStatus,
20
+ TeamMessage,
21
+ TeamState,
22
+ WorkerInfo,
23
+ } from "../types.js";
24
+ import {
25
+ parseJsonBody,
26
+ readRequestBody,
27
+ sendJson,
28
+ sendError,
29
+ generateId,
30
+ } from "../protocol.js";
31
+ import { listWorkspaceTree, readWorkspaceFile, readWorkspaceRawFile } from "../workspace-browser.js";
32
+ import { ROLES } from "../roles.js";
33
+ import { buildRepoSyncInfo, ensureControllerGitRepo, exportControllerGitBundle, importControllerGitBundle } from "../git-collaboration.js";
34
+ import type { LocalWorkerManager } from "./local-worker-manager.js";
35
+ import { TaskRouter } from "./task-router.js";
36
+ import { MessageRouter } from "./message-router.js";
37
+ import { TeamWebSocketServer } from "./websocket.js";
38
+ import type { WorkerProvisioningManager } from "./worker-provisioning.js";
39
+
40
+ export type ControllerHttpDeps = {
41
+ config: PluginConfig;
42
+ logger: PluginLogger;
43
+ runtime: OpenClawPluginApi["runtime"];
44
+ getTeamState: () => TeamState | null;
45
+ updateTeamState: (updater: (state: TeamState) => void) => TeamState;
46
+ taskRouter: TaskRouter;
47
+ messageRouter: MessageRouter;
48
+ wsServer: TeamWebSocketServer;
49
+ localWorkerManager?: LocalWorkerManager;
50
+ workerProvisioningManager?: WorkerProvisioningManager | null;
51
+ };
52
+
53
+ const MAX_TASK_EXECUTION_EVENTS = 250;
54
+ const MAX_RECENT_TASK_CONTEXT = 3;
55
+ const MAX_TASK_CONTEXT_SUMMARY_CHARS = 500;
56
+ const CONTROLLER_INTAKE_TIMEOUT_CAP_MS = 180_000;
57
+ const CONTROLLER_INTAKE_SESSION_PREFIX = "teamclaw-controller-web:";
58
+
59
+ function mapTaskStatusToExecutionStatus(taskStatus: TaskStatus, current?: TaskExecution["status"]): TaskExecution["status"] {
60
+ switch (taskStatus) {
61
+ case "completed":
62
+ return "completed";
63
+ case "failed":
64
+ return "failed";
65
+ case "in_progress":
66
+ case "review":
67
+ return "running";
68
+ case "pending":
69
+ case "assigned":
70
+ return current ?? "pending";
71
+ case "blocked":
72
+ return current ?? "running";
73
+ default:
74
+ return current ?? "pending";
75
+ }
76
+ }
77
+
78
+ function ensureTaskExecution(task: TaskInfo): TaskExecution {
79
+ if (!task.execution) {
80
+ task.execution = {
81
+ status: mapTaskStatusToExecutionStatus(task.status),
82
+ startedAt: task.startedAt,
83
+ endedAt: task.completedAt,
84
+ lastUpdatedAt: task.updatedAt,
85
+ events: [],
86
+ };
87
+ }
88
+
89
+ if (!Array.isArray(task.execution.events)) {
90
+ task.execution.events = [];
91
+ }
92
+
93
+ task.execution.status = task.execution.status ?? mapTaskStatusToExecutionStatus(task.status);
94
+ task.execution.startedAt = task.execution.startedAt ?? task.startedAt;
95
+ task.execution.endedAt = task.execution.endedAt ?? task.completedAt;
96
+ task.execution.lastUpdatedAt = task.execution.lastUpdatedAt ?? task.updatedAt;
97
+
98
+ return task.execution;
99
+ }
100
+
101
+ function appendTaskExecutionEvent(task: TaskInfo, input: TaskExecutionEventInput): TaskExecutionEvent {
102
+ const now = input.createdAt ?? Date.now();
103
+ const execution = ensureTaskExecution(task);
104
+
105
+ if (input.runId) {
106
+ execution.runId = input.runId;
107
+ }
108
+ if (input.sessionKey) {
109
+ execution.sessionKey = input.sessionKey;
110
+ }
111
+ if (input.status) {
112
+ execution.status = input.status;
113
+ } else {
114
+ execution.status = mapTaskStatusToExecutionStatus(task.status, execution.status);
115
+ }
116
+
117
+ if ((input.status === "running" || input.phase === "run_started") && !execution.startedAt) {
118
+ execution.startedAt = now;
119
+ }
120
+ if ((input.status === "running" || input.phase === "run_started") && !task.startedAt) {
121
+ task.startedAt = now;
122
+ }
123
+ if ((input.status === "running" || input.phase === "run_started") && (task.status === "pending" || task.status === "assigned")) {
124
+ task.status = "in_progress";
125
+ }
126
+
127
+ if (execution.status === "completed" || execution.status === "failed") {
128
+ execution.endedAt = execution.endedAt ?? now;
129
+ }
130
+
131
+ execution.lastUpdatedAt = now;
132
+ task.updatedAt = now;
133
+
134
+ const event: TaskExecutionEvent = {
135
+ id: generateId(),
136
+ type: input.type,
137
+ createdAt: now,
138
+ message: input.message,
139
+ phase: input.phase,
140
+ source: input.source,
141
+ stream: input.stream,
142
+ role: input.role ?? task.assignedRole,
143
+ workerId: input.workerId ?? task.assignedWorkerId,
144
+ };
145
+
146
+ execution.events.push(event);
147
+ if (execution.events.length > MAX_TASK_EXECUTION_EVENTS) {
148
+ execution.events = execution.events.slice(-MAX_TASK_EXECUTION_EVENTS);
149
+ }
150
+
151
+ return event;
152
+ }
153
+
154
+ function buildTaskExecutionSummary(execution?: TaskExecution): TaskExecutionSummary | undefined {
155
+ if (!execution) {
156
+ return undefined;
157
+ }
158
+
159
+ return {
160
+ status: execution.status,
161
+ runId: execution.runId,
162
+ startedAt: execution.startedAt,
163
+ endedAt: execution.endedAt,
164
+ lastUpdatedAt: execution.lastUpdatedAt,
165
+ eventCount: execution.events.length,
166
+ lastEvent: execution.events[execution.events.length - 1],
167
+ };
168
+ }
169
+
170
+ function serializeTask(task?: TaskInfo, includeExecutionEvents = false): Record<string, unknown> | undefined {
171
+ if (!task) {
172
+ return undefined;
173
+ }
174
+
175
+ const payload: Record<string, unknown> = { ...task };
176
+ delete payload.controllerSessionKey;
177
+ if (!task.execution) {
178
+ return payload;
179
+ }
180
+
181
+ payload.execution = includeExecutionEvents
182
+ ? {
183
+ status: task.execution.status,
184
+ runId: task.execution.runId,
185
+ startedAt: task.execution.startedAt,
186
+ endedAt: task.execution.endedAt,
187
+ lastUpdatedAt: task.execution.lastUpdatedAt,
188
+ events: task.execution.events.map((event) => ({ ...event })),
189
+ }
190
+ : buildTaskExecutionSummary(task.execution);
191
+
192
+ return payload;
193
+ }
194
+
195
+ function extractLastAssistantText(messages: unknown[]): string {
196
+ const assistantMessages = messages.filter((message): message is { role?: unknown; content?: unknown } => {
197
+ if (!message || typeof message !== "object") {
198
+ return false;
199
+ }
200
+ return (message as { role?: unknown }).role === "assistant";
201
+ });
202
+
203
+ const lastAssistant = assistantMessages[assistantMessages.length - 1];
204
+ if (!lastAssistant) {
205
+ return "";
206
+ }
207
+
208
+ if (typeof lastAssistant.content === "string") {
209
+ return lastAssistant.content;
210
+ }
211
+
212
+ if (Array.isArray(lastAssistant.content)) {
213
+ const textBlocks = lastAssistant.content
214
+ .filter((block): block is { type?: unknown; text?: unknown } => {
215
+ return !!block && typeof block === "object" && (block as { type?: unknown }).type === "text";
216
+ })
217
+ .map((block) => (typeof block.text === "string" ? block.text : ""))
218
+ .filter(Boolean);
219
+ if (textBlocks.length > 0) {
220
+ return textBlocks.join("\n");
221
+ }
222
+ }
223
+
224
+ return JSON.stringify(lastAssistant);
225
+ }
226
+
227
+ function normalizeControllerIntakeSessionKey(input: unknown): string {
228
+ const fallback = `${CONTROLLER_INTAKE_SESSION_PREFIX}default`;
229
+ if (typeof input !== "string") {
230
+ return fallback;
231
+ }
232
+
233
+ const trimmed = input.trim();
234
+ if (!trimmed || !/^[a-zA-Z0-9:_-]{1,120}$/.test(trimmed)) {
235
+ return fallback;
236
+ }
237
+
238
+ return trimmed.startsWith(CONTROLLER_INTAKE_SESSION_PREFIX)
239
+ ? trimmed
240
+ : `${CONTROLLER_INTAKE_SESSION_PREFIX}${trimmed}`;
241
+ }
242
+
243
+ function collectTaskIds(state: TeamState | null): Set<string> {
244
+ return new Set(Object.keys(state?.tasks ?? {}));
245
+ }
246
+
247
+ function tagControllerCreatedTasks(
248
+ taskIdsBeforeRun: Set<string>,
249
+ sessionKey: string,
250
+ deps: ControllerHttpDeps,
251
+ ): void {
252
+ deps.updateTeamState((state) => {
253
+ for (const task of Object.values(state.tasks)) {
254
+ if (taskIdsBeforeRun.has(task.id)) {
255
+ continue;
256
+ }
257
+ if (task.createdBy !== "controller" || task.controllerSessionKey) {
258
+ continue;
259
+ }
260
+ task.controllerSessionKey = sessionKey;
261
+ }
262
+ });
263
+ }
264
+
265
+ function buildControllerFollowUpMessage(task: TaskInfo): string {
266
+ const parts = [
267
+ `A controller-created TeamClaw task has ${task.status === "failed" ? "failed" : "completed"}.`,
268
+ `Task ID: ${task.id}`,
269
+ `Title: ${task.title}`,
270
+ task.assignedRole ? `Role: ${task.assignedRole}` : "",
271
+ "",
272
+ "## Original Task",
273
+ task.description || "No task description was recorded.",
274
+ ];
275
+
276
+ if (task.result) {
277
+ parts.push("", "## Task Result", task.result);
278
+ }
279
+ if (task.error) {
280
+ parts.push("", "## Task Error", task.error);
281
+ }
282
+
283
+ parts.push(
284
+ "",
285
+ "## Controller Follow-up",
286
+ "Continue orchestrating this same requirement.",
287
+ "Review the current TeamClaw state before acting.",
288
+ "Create only the next execution-ready task(s) whose prerequisites are now satisfied.",
289
+ "Do not duplicate tasks that already exist, are active, or are already completed.",
290
+ "If no additional task should be created yet, reply briefly and stop.",
291
+ );
292
+
293
+ return parts.filter(Boolean).join("\n");
294
+ }
295
+
296
+ async function continueControllerWorkflow(task: TaskInfo, deps: ControllerHttpDeps): Promise<void> {
297
+ if (task.createdBy !== "controller" || !task.controllerSessionKey) {
298
+ return;
299
+ }
300
+ await runControllerIntake(buildControllerFollowUpMessage(task), task.controllerSessionKey, deps);
301
+ }
302
+
303
+ async function runControllerIntake(
304
+ message: string,
305
+ sessionKey: string,
306
+ deps: ControllerHttpDeps,
307
+ ): Promise<{ sessionKey: string; runId: string; reply: string }> {
308
+ const taskIdsBeforeRun = collectTaskIds(deps.getTeamState());
309
+ const runResult = await deps.runtime.subagent.run({
310
+ sessionKey,
311
+ message,
312
+ idempotencyKey: `controller-intake-${generateId()}`,
313
+ });
314
+
315
+ const waitResult = await deps.runtime.subagent.waitForRun({
316
+ runId: runResult.runId,
317
+ timeoutMs: Math.min(deps.config.taskTimeoutMs, CONTROLLER_INTAKE_TIMEOUT_CAP_MS),
318
+ });
319
+
320
+ if (waitResult.status === "timeout") {
321
+ tagControllerCreatedTasks(taskIdsBeforeRun, sessionKey, deps);
322
+ throw new Error("Controller intake timed out");
323
+ }
324
+ if (waitResult.status !== "ok") {
325
+ tagControllerCreatedTasks(taskIdsBeforeRun, sessionKey, deps);
326
+ throw new Error(waitResult.error || "Controller intake failed");
327
+ }
328
+
329
+ tagControllerCreatedTasks(taskIdsBeforeRun, sessionKey, deps);
330
+
331
+ const sessionMessages = await deps.runtime.subagent.getSessionMessages({
332
+ sessionKey,
333
+ limit: 100,
334
+ });
335
+ const reply = extractLastAssistantText(sessionMessages.messages);
336
+
337
+ return {
338
+ sessionKey,
339
+ runId: runResult.runId,
340
+ reply: reply || "Controller completed the intake run but did not return any text.",
341
+ };
342
+ }
343
+
344
+ function summarizeTaskForAssignment(task: TaskInfo): string {
345
+ const lastExecutionMessage = task.execution?.events[task.execution.events.length - 1]?.message;
346
+ const raw = task.result || task.progress || lastExecutionMessage || task.description || "";
347
+ const normalized = raw.replace(/\s+/g, " ").trim();
348
+ if (!normalized) {
349
+ return "No upstream summary available.";
350
+ }
351
+ if (normalized.length <= MAX_TASK_CONTEXT_SUMMARY_CHARS) {
352
+ return normalized;
353
+ }
354
+ return `${normalized.slice(0, MAX_TASK_CONTEXT_SUMMARY_CHARS).trimEnd()}…`;
355
+ }
356
+
357
+ function buildRecentCompletedTaskContext(task: TaskInfo, state: TeamState | null): string {
358
+ if (!state) {
359
+ return "";
360
+ }
361
+
362
+ const recentCompletedTasks = Object.values(state.tasks)
363
+ .filter((candidate) => candidate.id !== task.id && candidate.status === "completed")
364
+ .filter((candidate) => (candidate.completedAt ?? candidate.updatedAt) <= task.createdAt)
365
+ .sort((a, b) => (b.completedAt ?? b.updatedAt) - (a.completedAt ?? a.updatedAt))
366
+ .slice(0, MAX_RECENT_TASK_CONTEXT)
367
+ .reverse();
368
+
369
+ if (recentCompletedTasks.length === 0) {
370
+ return "";
371
+ }
372
+
373
+ return [
374
+ "## Recent Completed Team Deliverables",
375
+ "Use these upstream outputs before requesting clarification.",
376
+ "If a summary references a filename or task ID, search the shared workspace for it first.",
377
+ "Do not try to inspect another worker's OpenClaw session or session key directly; those sessions are isolated per worker.",
378
+ ...recentCompletedTasks.map((candidate) => {
379
+ const roleLabel = candidate.assignedRole
380
+ ? (ROLES.find((role) => role.id === candidate.assignedRole)?.label ?? candidate.assignedRole)
381
+ : "Unassigned";
382
+ return `- [${candidate.id}] ${candidate.title} (${roleLabel}): ${summarizeTaskForAssignment(candidate)}`;
383
+ }),
384
+ ].join("\n");
385
+ }
386
+
387
+ function buildTaskAssignmentDescription(task: TaskInfo, state: TeamState | null, repoInfo?: RepoSyncInfo): string {
388
+ const parts = [task.description];
389
+ const recentContext = buildRecentCompletedTaskContext(task, state);
390
+ if (recentContext) {
391
+ parts.push("", recentContext);
392
+ }
393
+ if (repoInfo?.enabled) {
394
+ parts.push("", buildRepoTaskContext(repoInfo));
395
+ }
396
+ return parts.join("\n");
397
+ }
398
+
399
+ function buildRepoTaskContext(repoInfo: RepoSyncInfo): string {
400
+ const lines = [
401
+ "## TeamClaw Git Collaboration",
402
+ "- TeamClaw manages a git-backed project workspace for this task.",
403
+ `- Sync mode: ${repoInfo.mode}.`,
404
+ `- Default branch: ${repoInfo.defaultBranch}.`,
405
+ ];
406
+
407
+ if (repoInfo.headCommit) {
408
+ const headSummary = repoInfo.headSummary ? ` "${repoInfo.headSummary}"` : "";
409
+ lines.push(`- Current HEAD: ${repoInfo.headCommit}${headSummary}.`);
410
+ }
411
+
412
+ lines.push("- TeamClaw syncs the workspace checkout before task execution when needed.");
413
+ lines.push("- Treat the current workspace as the canonical repo checkout; do not delete `.git` or replace the repo with ad-hoc archives.");
414
+ return lines.join("\n");
415
+ }
416
+
417
+ async function refreshControllerRepoState(deps: ControllerHttpDeps): Promise<GitRepoState | null> {
418
+ if (!deps.config.gitEnabled) {
419
+ return null;
420
+ }
421
+
422
+ try {
423
+ const repo = await ensureControllerGitRepo(deps.config, deps.logger);
424
+ if (repo) {
425
+ deps.updateTeamState((s) => {
426
+ s.repo = repo;
427
+ });
428
+ }
429
+ return repo;
430
+ } catch (err) {
431
+ const message = err instanceof Error ? err.message : String(err);
432
+ deps.logger.warn(`Controller: failed to refresh git repo state: ${message}`);
433
+ deps.updateTeamState((s) => {
434
+ if (s.repo?.enabled) {
435
+ s.repo = {
436
+ ...s.repo,
437
+ error: message,
438
+ lastPreparedAt: Date.now(),
439
+ };
440
+ }
441
+ });
442
+ return deps.getTeamState()?.repo ?? null;
443
+ }
444
+ }
445
+
446
+ function scheduleProvisioningReconcile(deps: ControllerHttpDeps, reason: string): void {
447
+ void deps.workerProvisioningManager?.requestReconcile(reason);
448
+ }
449
+
450
+ function broadcastTaskExecutionEvent(
451
+ taskId: string,
452
+ task: TaskInfo,
453
+ event: TaskExecutionEvent,
454
+ deps: ControllerHttpDeps,
455
+ ): void {
456
+ deps.wsServer.broadcastUpdate({
457
+ type: "task:execution",
458
+ data: {
459
+ taskId,
460
+ event,
461
+ execution: buildTaskExecutionSummary(task.execution),
462
+ },
463
+ });
464
+ }
465
+
466
+ function recordTaskExecutionEvent(
467
+ taskId: string,
468
+ input: TaskExecutionEventInput,
469
+ deps: ControllerHttpDeps,
470
+ ): { task?: TaskInfo; event?: TaskExecutionEvent; statusChanged: boolean } {
471
+ const { updateTeamState, wsServer } = deps;
472
+ let statusChanged = false;
473
+ let event: TaskExecutionEvent | undefined;
474
+
475
+ const state = updateTeamState((s) => {
476
+ const task = s.tasks[taskId];
477
+ if (!task) {
478
+ return;
479
+ }
480
+
481
+ const previousStatus = task.status;
482
+ event = appendTaskExecutionEvent(task, input);
483
+ statusChanged = previousStatus !== task.status;
484
+ });
485
+
486
+ const updatedTask = state.tasks[taskId];
487
+ if (updatedTask && event) {
488
+ broadcastTaskExecutionEvent(taskId, updatedTask, event, deps);
489
+ if (statusChanged) {
490
+ wsServer.broadcastUpdate({ type: "task:updated", data: serializeTask(updatedTask) });
491
+ }
492
+ }
493
+
494
+ return { task: updatedTask, event, statusChanged };
495
+ }
496
+
497
+ function canAcceptWorkerUpdate(task: TaskInfo | undefined, workerId: string): boolean {
498
+ if (!task || task.assignedWorkerId !== workerId || task.completedAt) {
499
+ return false;
500
+ }
501
+
502
+ return task.status === "assigned" ||
503
+ task.status === "in_progress" ||
504
+ task.status === "review" ||
505
+ task.status === "completed" ||
506
+ task.status === "failed";
507
+ }
508
+
509
+ async function cancelTaskExecution(
510
+ taskId: string,
511
+ workerId: string | undefined,
512
+ reason: string,
513
+ deps: ControllerHttpDeps,
514
+ ): Promise<void> {
515
+ if (!workerId) {
516
+ return;
517
+ }
518
+
519
+ const worker = deps.getTeamState()?.workers[workerId];
520
+ if (!worker) {
521
+ return;
522
+ }
523
+
524
+ let cancelled = false;
525
+ if (deps.localWorkerManager?.isLocalWorkerId(workerId)) {
526
+ cancelled = await deps.localWorkerManager.cancelTaskExecution(workerId, taskId);
527
+ } else {
528
+ try {
529
+ const res = await fetch(`${worker.url}/api/v1/tasks/${taskId}/cancel`, {
530
+ method: "POST",
531
+ });
532
+ cancelled = res.ok;
533
+ if (!res.ok) {
534
+ deps.logger.warn(`Controller: worker cancel failed for ${taskId} on ${workerId} (${res.status})`);
535
+ }
536
+ } catch (err) {
537
+ deps.logger.warn(`Controller: failed to cancel task ${taskId} on ${workerId}: ${String(err)}`);
538
+ }
539
+ }
540
+
541
+ if (!cancelled) {
542
+ return;
543
+ }
544
+
545
+ recordTaskExecutionEvent(taskId, {
546
+ type: "lifecycle",
547
+ phase: "execution_cancelled",
548
+ source: "controller",
549
+ message: `Cancelled active execution before ${reason}.`,
550
+ workerId,
551
+ }, deps);
552
+ }
553
+
554
+ function serveStaticFile(res: ServerResponse, filePath: string, contentType: string): void {
555
+ try {
556
+ const content = fs.readFileSync(filePath);
557
+ res.writeHead(200, {
558
+ "Content-Type": contentType,
559
+ "Access-Control-Allow-Origin": "*",
560
+ });
561
+ res.end(content);
562
+ } catch {
563
+ sendError(res, 404, "File not found");
564
+ }
565
+ }
566
+
567
+ function workspaceRequestErrorStatus(err: unknown): number {
568
+ if (err && typeof err === "object" && "code" in err && (err as { code?: unknown }).code === "ENOENT") {
569
+ return 404;
570
+ }
571
+ return 400;
572
+ }
573
+
574
+ function workspaceRequestErrorMessage(err: unknown): string {
575
+ return err instanceof Error ? err.message : "Workspace request failed";
576
+ }
577
+
578
+ function applyTaskResult(
579
+ taskId: string,
580
+ result: string,
581
+ error: string | undefined,
582
+ deps: ControllerHttpDeps,
583
+ ): TaskInfo | undefined {
584
+ const { logger, updateTeamState, wsServer } = deps;
585
+ let completionEvent: TaskExecutionEvent | undefined;
586
+
587
+ const state = updateTeamState((s) => {
588
+ const task = s.tasks[taskId];
589
+ if (!task) return;
590
+
591
+ task.status = error ? "failed" : "completed";
592
+ task.result = result;
593
+ task.error = error;
594
+ task.completedAt = Date.now();
595
+ task.updatedAt = Date.now();
596
+ completionEvent = appendTaskExecutionEvent(task, {
597
+ type: error ? "error" : "lifecycle",
598
+ phase: error ? "result_failed" : "result_completed",
599
+ source: "controller",
600
+ status: error ? "failed" : "completed",
601
+ message: error ? `Task failed: ${error}` : "Task completed successfully.",
602
+ workerId: task.assignedWorkerId,
603
+ role: task.assignedRole,
604
+ });
605
+
606
+ if (task.assignedWorkerId && s.workers[task.assignedWorkerId]) {
607
+ s.workers[task.assignedWorkerId].status = "idle";
608
+ s.workers[task.assignedWorkerId].currentTaskId = undefined;
609
+ }
610
+ });
611
+
612
+ const updatedTask = state.tasks[taskId];
613
+ if (updatedTask) {
614
+ if (completionEvent) {
615
+ broadcastTaskExecutionEvent(taskId, updatedTask, completionEvent, deps);
616
+ }
617
+ wsServer.broadcastUpdate({ type: "task:completed", data: serializeTask(updatedTask) });
618
+ logger.info(`Controller: task ${taskId} ${error ? "failed" : "completed"}`);
619
+ if (updatedTask.assignedWorkerId) {
620
+ void autoAssignPendingTasks(deps, updatedTask.assignedWorkerId).catch((err) => {
621
+ logger.warn(
622
+ `Controller: failed to auto-assign pending tasks after result for ${taskId}: ${String(err)}`,
623
+ );
624
+ });
625
+ }
626
+ scheduleProvisioningReconcile(deps, `task-result:${taskId}`);
627
+ if (!error && updatedTask.createdBy === "controller" && updatedTask.controllerSessionKey) {
628
+ void continueControllerWorkflow(updatedTask, deps).catch((err) => {
629
+ logger.warn(
630
+ `Controller: failed to continue intake workflow after ${taskId}: ${String(err)}`,
631
+ );
632
+ });
633
+ }
634
+ }
635
+
636
+ return updatedTask;
637
+ }
638
+
639
+ function revertTaskAssignment(taskId: string, workerId: string, deps: ControllerHttpDeps): TaskInfo | undefined {
640
+ const { updateTeamState, wsServer } = deps;
641
+ let revertEvent: TaskExecutionEvent | undefined;
642
+
643
+ const state = updateTeamState((s) => {
644
+ const task = s.tasks[taskId];
645
+ if (!task) {
646
+ return;
647
+ }
648
+
649
+ if (task.assignedWorkerId === workerId) {
650
+ task.status = "pending";
651
+ task.assignedWorkerId = undefined;
652
+ task.updatedAt = Date.now();
653
+ revertEvent = appendTaskExecutionEvent(task, {
654
+ type: "error",
655
+ phase: "assignment_reverted",
656
+ source: "controller",
657
+ message: `Assignment to ${workerId} was reverted; task returned to pending.`,
658
+ });
659
+ }
660
+
661
+ const worker = s.workers[workerId];
662
+ if (worker?.currentTaskId === taskId) {
663
+ worker.status = "idle";
664
+ worker.currentTaskId = undefined;
665
+ }
666
+ });
667
+
668
+ const updatedTask = state.tasks[taskId];
669
+ if (updatedTask) {
670
+ if (revertEvent) {
671
+ broadcastTaskExecutionEvent(taskId, updatedTask, revertEvent, deps);
672
+ }
673
+ wsServer.broadcastUpdate({ type: "task:updated", data: serializeTask(updatedTask) });
674
+ void autoAssignPendingTasks(deps).catch(() => {
675
+ // Best-effort retry path; assignment failure is already surfaced via task state.
676
+ });
677
+ scheduleProvisioningReconcile(deps, `assignment-reverted:${taskId}`);
678
+ }
679
+ return updatedTask;
680
+ }
681
+
682
+ async function deliverMessageToWorker(
683
+ worker: WorkerInfo,
684
+ message: TeamMessage,
685
+ deps: ControllerHttpDeps,
686
+ ): Promise<void> {
687
+ const { localWorkerManager } = deps;
688
+
689
+ if (localWorkerManager?.isLocalWorkerId(worker.id)) {
690
+ const queued = await localWorkerManager.queueMessage(worker.id, message);
691
+ if (queued) {
692
+ return;
693
+ }
694
+
695
+ deps.logger.warn(`Controller: local message path unavailable for ${worker.id}, falling back to worker URL`);
696
+ }
697
+
698
+ const res = await fetch(`${worker.url}/api/v1/messages`, {
699
+ method: "POST",
700
+ headers: { "Content-Type": "application/json" },
701
+ body: JSON.stringify(message),
702
+ });
703
+
704
+ if (!res.ok) {
705
+ throw new Error(`worker ${worker.id} responded with ${res.status}`);
706
+ }
707
+ }
708
+
709
+ async function routeDirectMessage(
710
+ message: TeamMessage,
711
+ deps: ControllerHttpDeps,
712
+ ): Promise<boolean> {
713
+ const { getTeamState, logger, messageRouter } = deps;
714
+ const state = getTeamState();
715
+ if (!state) {
716
+ return false;
717
+ }
718
+
719
+ const routed = messageRouter.routeDirectMessage(message, state.workers);
720
+ if (!routed) {
721
+ return false;
722
+ }
723
+
724
+ try {
725
+ await deliverMessageToWorker(routed.worker, routed.message, deps);
726
+ } catch (err) {
727
+ logger.warn(`Controller: failed to deliver message to ${routed.worker.id}: ${String(err)}`);
728
+ }
729
+
730
+ return true;
731
+ }
732
+
733
+ async function dispatchTaskToWorker(
734
+ taskId: string,
735
+ worker: WorkerInfo,
736
+ deps: ControllerHttpDeps,
737
+ ): Promise<void> {
738
+ const { getTeamState, localWorkerManager } = deps;
739
+ const state = getTeamState();
740
+ const task = state?.tasks[taskId];
741
+ if (!task) {
742
+ throw new Error(`task ${taskId} not found`);
743
+ }
744
+
745
+ const sharedWorkspace = localWorkerManager?.isLocalWorkerId(worker.id) ?? false;
746
+ const repoState = await refreshControllerRepoState(deps);
747
+ const repoInfo = buildRepoSyncInfo(repoState, sharedWorkspace);
748
+ const description = buildTaskAssignmentDescription(task, state ?? null, repoInfo);
749
+ const assignment: TaskAssignmentPayload = {
750
+ taskId: task.id,
751
+ title: task.title,
752
+ description,
753
+ priority: task.priority,
754
+ repo: repoInfo,
755
+ };
756
+
757
+ if (localWorkerManager?.isLocalWorkerId(worker.id)) {
758
+ const accepted = await localWorkerManager.dispatchTask(worker.id, assignment);
759
+ if (accepted) {
760
+ return;
761
+ }
762
+
763
+ deps.logger.warn(`Controller: local dispatch path unavailable for ${worker.id}, falling back to worker URL`);
764
+ }
765
+
766
+ const res = await fetch(`${worker.url}/api/v1/tasks/assign`, {
767
+ method: "POST",
768
+ headers: { "Content-Type": "application/json" },
769
+ body: JSON.stringify(assignment),
770
+ });
771
+
772
+ if (!res.ok) {
773
+ throw new Error(`worker ${worker.id} responded with ${res.status}`);
774
+ }
775
+ }
776
+
777
+ async function assignTaskToWorker(
778
+ taskId: string,
779
+ worker: WorkerInfo,
780
+ deps: ControllerHttpDeps,
781
+ options?: {
782
+ assignedRole?: RoleId;
783
+ },
784
+ ): Promise<TaskInfo | undefined> {
785
+ const { logger, updateTeamState } = deps;
786
+ let assignmentApplied = false;
787
+
788
+ updateTeamState((s) => {
789
+ const task = s.tasks[taskId];
790
+ const targetWorker = s.workers[worker.id];
791
+ if (!task || !targetWorker) {
792
+ return;
793
+ }
794
+ if (targetWorker.status !== "idle") {
795
+ return;
796
+ }
797
+ const canAssignCurrentTask =
798
+ task.status === "pending" ||
799
+ (task.status === "assigned" && task.assignedWorkerId === worker.id);
800
+ if (!canAssignCurrentTask) {
801
+ return;
802
+ }
803
+
804
+ task.status = "assigned";
805
+ task.assignedWorkerId = worker.id;
806
+ if (options?.assignedRole) {
807
+ task.assignedRole = options.assignedRole;
808
+ }
809
+ task.updatedAt = Date.now();
810
+
811
+ targetWorker.status = "busy";
812
+ targetWorker.currentTaskId = taskId;
813
+ assignmentApplied = true;
814
+ });
815
+
816
+ if (!assignmentApplied) {
817
+ return deps.getTeamState()?.tasks[taskId];
818
+ }
819
+
820
+ try {
821
+ await dispatchTaskToWorker(taskId, worker, deps);
822
+ } catch (err) {
823
+ logger.warn(`Controller: failed to dispatch task ${taskId} to ${worker.id}: ${String(err)}`);
824
+ recordTaskExecutionEvent(taskId, {
825
+ type: "error",
826
+ phase: "dispatch_failed",
827
+ source: "controller",
828
+ message: `Failed to dispatch task to ${worker.id}: ${String(err)}`,
829
+ workerId: worker.id,
830
+ role: options?.assignedRole ?? worker.role,
831
+ }, deps);
832
+ return revertTaskAssignment(taskId, worker.id, deps);
833
+ }
834
+
835
+ recordTaskExecutionEvent(taskId, {
836
+ type: "lifecycle",
837
+ phase: "assigned",
838
+ source: "controller",
839
+ message: `Assigned to ${worker.label || worker.id}.`,
840
+ workerId: worker.id,
841
+ role: options?.assignedRole ?? worker.role,
842
+ }, deps);
843
+
844
+ return deps.getTeamState()?.tasks[taskId];
845
+ }
846
+
847
+ async function autoAssignPendingTasks(
848
+ deps: ControllerHttpDeps,
849
+ preferredWorkerId?: string,
850
+ ): Promise<TaskInfo[]> {
851
+ const { getTeamState, taskRouter, wsServer, logger } = deps;
852
+ const attemptedPairs = new Set<string>();
853
+ const assignedTasks: TaskInfo[] = [];
854
+
855
+ while (true) {
856
+ const state = getTeamState();
857
+ if (!state) {
858
+ break;
859
+ }
860
+
861
+ const nextAssignment = taskRouter
862
+ .autoAssignPendingTasks(state.tasks, state.workers)
863
+ .filter(({ worker }) => !preferredWorkerId || worker.id === preferredWorkerId)
864
+ .find(({ task, worker }) => !attemptedPairs.has(`${task.id}:${worker.id}`));
865
+
866
+ if (!nextAssignment) {
867
+ break;
868
+ }
869
+
870
+ const pairKey = `${nextAssignment.task.id}:${nextAssignment.worker.id}`;
871
+ attemptedPairs.add(pairKey);
872
+
873
+ const updatedTask = await assignTaskToWorker(nextAssignment.task.id, nextAssignment.worker, deps, {
874
+ assignedRole: nextAssignment.task.assignedRole,
875
+ });
876
+
877
+ if (updatedTask?.status === "assigned" && updatedTask.assignedWorkerId === nextAssignment.worker.id) {
878
+ wsServer.broadcastUpdate({ type: "task:updated", data: serializeTask(updatedTask) });
879
+ logger.info(
880
+ `Controller: auto-assigned pending task ${updatedTask.id} to ${nextAssignment.worker.id}`,
881
+ );
882
+ assignedTasks.push(updatedTask);
883
+ }
884
+ }
885
+
886
+ scheduleProvisioningReconcile(deps, preferredWorkerId
887
+ ? `auto-assign:${preferredWorkerId}`
888
+ : "auto-assign");
889
+
890
+ return assignedTasks;
891
+ }
892
+
893
+ export function createControllerHttpServer(deps: ControllerHttpDeps): http.Server {
894
+ const { logger, wsServer } = deps;
895
+
896
+ const server = http.createServer(async (req: IncomingMessage, res: ServerResponse) => {
897
+ // CORS preflight
898
+ if (req.method === "OPTIONS") {
899
+ res.writeHead(200, {
900
+ "Access-Control-Allow-Origin": "*",
901
+ "Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE, OPTIONS",
902
+ "Access-Control-Allow-Headers": "Content-Type",
903
+ });
904
+ res.end();
905
+ return;
906
+ }
907
+
908
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
909
+ const pathname = url.pathname;
910
+
911
+ try {
912
+ await handleRequest(req, res, pathname, deps);
913
+ } catch (err) {
914
+ logger.error(`Controller HTTP error: ${err instanceof Error ? err.message : String(err)}`);
915
+ sendError(res, 500, "Internal server error");
916
+ }
917
+ });
918
+
919
+ // Attach WebSocket
920
+ wsServer.attach(server);
921
+
922
+ return server;
923
+ }
924
+
925
+ async function handleRequest(
926
+ req: IncomingMessage,
927
+ res: ServerResponse,
928
+ pathname: string,
929
+ deps: ControllerHttpDeps,
930
+ ): Promise<void> {
931
+ const { config, logger, getTeamState, updateTeamState, taskRouter, messageRouter, wsServer } = deps;
932
+ const requestUrl = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
933
+
934
+ // ==================== Web UI ====================
935
+ if (req.method === "GET" && (pathname === "/ui" || pathname === "/ui/")) {
936
+ const uiPath = path.join(import.meta.dirname, "..", "ui");
937
+ serveStaticFile(res, path.join(uiPath, "index.html"), "text/html; charset=utf-8");
938
+ return;
939
+ }
940
+
941
+ if (req.method === "GET" && pathname.startsWith("/ui/")) {
942
+ const uiPath = path.join(import.meta.dirname, "..", "ui");
943
+ const file = pathname.slice(4); // remove "/ui/"
944
+ if (file.endsWith(".css")) {
945
+ serveStaticFile(res, path.join(uiPath, file), "text/css; charset=utf-8");
946
+ } else if (file.endsWith(".js")) {
947
+ serveStaticFile(res, path.join(uiPath, file), "application/javascript; charset=utf-8");
948
+ } else {
949
+ serveStaticFile(res, path.join(uiPath, file), "application/octet-stream");
950
+ }
951
+ return;
952
+ }
953
+
954
+ // ==================== Workspace Browser ====================
955
+
956
+ if (req.method === "GET" && pathname === "/api/v1/workspace/tree") {
957
+ try {
958
+ sendJson(res, 200, await listWorkspaceTree());
959
+ } catch (err) {
960
+ sendError(res, workspaceRequestErrorStatus(err), workspaceRequestErrorMessage(err));
961
+ }
962
+ return;
963
+ }
964
+
965
+ if (req.method === "GET" && pathname === "/api/v1/workspace/file") {
966
+ const relativePath = requestUrl.searchParams.get("path") ?? "";
967
+ if (!relativePath) {
968
+ sendError(res, 400, "path is required");
969
+ return;
970
+ }
971
+
972
+ try {
973
+ sendJson(res, 200, { file: await readWorkspaceFile(relativePath) });
974
+ } catch (err) {
975
+ sendError(res, workspaceRequestErrorStatus(err), workspaceRequestErrorMessage(err));
976
+ }
977
+ return;
978
+ }
979
+
980
+ if (req.method === "GET" && pathname.startsWith("/api/v1/workspace/raw/")) {
981
+ const rawPathname = pathname.slice("/api/v1/workspace/raw/".length);
982
+ if (!rawPathname) {
983
+ sendError(res, 400, "path is required");
984
+ return;
985
+ }
986
+
987
+ try {
988
+ const relativePath = decodeURIComponent(rawPathname);
989
+ const file = await readWorkspaceRawFile(relativePath);
990
+ res.writeHead(200, {
991
+ "Content-Type": file.contentType,
992
+ "Cache-Control": "no-store",
993
+ "Access-Control-Allow-Origin": "*",
994
+ "X-Content-Type-Options": "nosniff",
995
+ });
996
+ res.end(file.content);
997
+ } catch (err) {
998
+ sendError(res, workspaceRequestErrorStatus(err), workspaceRequestErrorMessage(err));
999
+ }
1000
+ return;
1001
+ }
1002
+
1003
+ // ==================== Worker Management ====================
1004
+
1005
+ // POST /api/v1/workers/register
1006
+ if (req.method === "POST" && pathname === "/api/v1/workers/register") {
1007
+ const body = await parseJsonBody(req);
1008
+ const workerId = typeof body.workerId === "string" ? body.workerId : "";
1009
+ const role = typeof body.role === "string" ? body.role as RoleId : "";
1010
+ const label = typeof body.label === "string" ? body.label : role;
1011
+ const workerUrl = typeof body.url === "string" ? body.url : "";
1012
+ const capabilities = Array.isArray(body.capabilities) ? body.capabilities as string[] : [];
1013
+ const launchToken = typeof body.launchToken === "string" ? body.launchToken : undefined;
1014
+
1015
+ if (!workerId || !role || !workerUrl) {
1016
+ sendError(res, 400, "workerId, role, and url are required");
1017
+ return;
1018
+ }
1019
+
1020
+ const registrationValidation = deps.workerProvisioningManager?.validateRegistration(workerId, role, launchToken);
1021
+ if (registrationValidation && !registrationValidation.ok) {
1022
+ sendError(res, 403, registrationValidation.reason ?? "Worker registration rejected");
1023
+ return;
1024
+ }
1025
+
1026
+ const state = updateTeamState((s) => {
1027
+ s.workers[workerId] = {
1028
+ id: workerId,
1029
+ role,
1030
+ label,
1031
+ status: "idle",
1032
+ transport: "http",
1033
+ url: workerUrl,
1034
+ lastHeartbeat: Date.now(),
1035
+ capabilities,
1036
+ registeredAt: Date.now(),
1037
+ };
1038
+ });
1039
+ deps.workerProvisioningManager?.onWorkerRegistered(workerId);
1040
+
1041
+ wsServer.broadcastUpdate({ type: "worker:online", data: state.workers[workerId] });
1042
+ logger.info(`Controller: worker registered - ${label} (${workerId}) at ${workerUrl}`);
1043
+ sendJson(res, 201, { status: "registered", worker: state.workers[workerId] });
1044
+ void autoAssignPendingTasks(deps, workerId).catch((err) => {
1045
+ logger.warn(`Controller: failed to auto-assign after worker registration (${workerId}): ${String(err)}`);
1046
+ });
1047
+ return;
1048
+ }
1049
+
1050
+ // DELETE /api/v1/workers/:id
1051
+ if (req.method === "DELETE" && pathname.match(/^\/api\/v1\/workers\/[^/]+$/)) {
1052
+ const workerId = pathname.split("/").pop()!;
1053
+ if (deps.localWorkerManager?.isLocalWorkerId(workerId)) {
1054
+ sendError(res, 400, "Local workers are managed by controller config");
1055
+ return;
1056
+ }
1057
+
1058
+ if (deps.workerProvisioningManager?.hasManagedWorker(workerId)) {
1059
+ await deps.workerProvisioningManager.onWorkerRemoved(workerId, "worker delete requested");
1060
+ }
1061
+
1062
+ const affectedTaskIds: string[] = [];
1063
+ updateTeamState((s) => {
1064
+ const worker = s.workers[workerId];
1065
+ if (worker) {
1066
+ worker.status = "offline";
1067
+ worker.currentTaskId = undefined;
1068
+ delete s.workers[workerId];
1069
+ }
1070
+
1071
+ for (const task of Object.values(s.tasks)) {
1072
+ if (
1073
+ task.assignedWorkerId === workerId &&
1074
+ task.status !== "completed" &&
1075
+ task.status !== "failed" &&
1076
+ task.status !== "blocked"
1077
+ ) {
1078
+ task.status = "pending";
1079
+ task.assignedWorkerId = undefined;
1080
+ task.updatedAt = Date.now();
1081
+ affectedTaskIds.push(task.id);
1082
+ }
1083
+ }
1084
+ });
1085
+
1086
+ await autoAssignPendingTasks(deps);
1087
+ for (const taskId of affectedTaskIds) {
1088
+ const task = getTeamState()?.tasks[taskId];
1089
+ if (task) {
1090
+ wsServer.broadcastUpdate({ type: "task:updated", data: serializeTask(task) });
1091
+ }
1092
+ }
1093
+ wsServer.broadcastUpdate({ type: "worker:offline", data: { workerId } });
1094
+ logger.info(`Controller: worker removed - ${workerId}`);
1095
+ sendJson(res, 200, { status: "removed" });
1096
+ return;
1097
+ }
1098
+
1099
+ // GET /api/v1/workers
1100
+ if (req.method === "GET" && pathname === "/api/v1/workers") {
1101
+ const state = getTeamState();
1102
+ const workers = state ? Object.values(state.workers) : [];
1103
+ sendJson(res, 200, { workers });
1104
+ return;
1105
+ }
1106
+
1107
+ // POST /api/v1/workers/:id/heartbeat
1108
+ if (req.method === "POST" && pathname.match(/^\/api\/v1\/workers\/[^/]+\/heartbeat$/)) {
1109
+ const workerId = pathname.split("/")[4]!;
1110
+ const body = await parseJsonBody(req);
1111
+ const status = typeof body.status === "string" ? body.status as WorkerInfo["status"] : "idle";
1112
+ const currentTaskId = typeof body.currentTaskId === "string" ? body.currentTaskId : undefined;
1113
+
1114
+ updateTeamState((s) => {
1115
+ if (s.workers[workerId]) {
1116
+ s.workers[workerId].lastHeartbeat = Date.now();
1117
+ s.workers[workerId].status = status;
1118
+ s.workers[workerId].currentTaskId = currentTaskId;
1119
+ }
1120
+ });
1121
+ deps.workerProvisioningManager?.onWorkerHeartbeat(workerId, status);
1122
+
1123
+ if (status === "idle") {
1124
+ await autoAssignPendingTasks(deps, workerId);
1125
+ } else {
1126
+ scheduleProvisioningReconcile(deps, `heartbeat:${workerId}:${status}`);
1127
+ }
1128
+ sendJson(res, 200, { status: "ok" });
1129
+ return;
1130
+ }
1131
+
1132
+ // ==================== Task Management ====================
1133
+
1134
+ // POST /api/v1/tasks
1135
+ if (req.method === "POST" && pathname === "/api/v1/tasks") {
1136
+ const body = await parseJsonBody(req);
1137
+ const title = typeof body.title === "string" ? body.title : "";
1138
+ const description = typeof body.description === "string" ? body.description : "";
1139
+ const priority = typeof body.priority === "string" ? body.priority as TaskPriority : "medium";
1140
+ const assignedRole = typeof body.assignedRole === "string" ? body.assignedRole as RoleId : undefined;
1141
+ const createdBy = typeof body.createdBy === "string" ? body.createdBy : "boss";
1142
+
1143
+ if (!title) {
1144
+ sendError(res, 400, "title is required");
1145
+ return;
1146
+ }
1147
+
1148
+ const taskId = generateId();
1149
+ const now = Date.now();
1150
+ const repoState = await refreshControllerRepoState(deps);
1151
+
1152
+ const task: TaskInfo = {
1153
+ id: taskId,
1154
+ title,
1155
+ description,
1156
+ status: "pending",
1157
+ priority,
1158
+ assignedRole,
1159
+ createdBy,
1160
+ createdAt: now,
1161
+ updatedAt: now,
1162
+ };
1163
+
1164
+ updateTeamState((s) => {
1165
+ s.tasks[taskId] = task;
1166
+ });
1167
+ recordTaskExecutionEvent(taskId, {
1168
+ type: "lifecycle",
1169
+ phase: "created",
1170
+ source: "controller",
1171
+ status: "pending",
1172
+ message: `Task created by ${createdBy}.`,
1173
+ role: assignedRole,
1174
+ }, deps);
1175
+ if (repoState?.enabled) {
1176
+ recordTaskExecutionEvent(taskId, {
1177
+ type: "lifecycle",
1178
+ phase: "repo_ready",
1179
+ source: "controller",
1180
+ status: "pending",
1181
+ message: repoState.remoteReady && repoState.remoteUrl
1182
+ ? `Git collaboration ready on ${repoState.defaultBranch} with remote ${repoState.remoteUrl}.`
1183
+ : `Git collaboration ready on ${repoState.defaultBranch} using controller-managed bundle sync.`,
1184
+ role: assignedRole,
1185
+ }, deps);
1186
+ }
1187
+
1188
+ await autoAssignPendingTasks(deps);
1189
+
1190
+ const updatedTask = getTeamState()?.tasks[taskId];
1191
+ wsServer.broadcastUpdate({ type: "task:created", data: serializeTask(updatedTask) });
1192
+ sendJson(res, 201, { task: serializeTask(updatedTask) });
1193
+ return;
1194
+ }
1195
+
1196
+ // GET /api/v1/tasks
1197
+ if (req.method === "GET" && pathname === "/api/v1/tasks") {
1198
+ const state = getTeamState();
1199
+ const tasks = state ? Object.values(state.tasks).map((task) => serializeTask(task)) : [];
1200
+ sendJson(res, 200, { tasks });
1201
+ return;
1202
+ }
1203
+
1204
+ // GET /api/v1/tasks/:id
1205
+ if (req.method === "GET" && pathname.match(/^\/api\/v1\/tasks\/[^/]+$/)) {
1206
+ const taskId = pathname.split("/").pop()!;
1207
+ const state = getTeamState();
1208
+ const task = state?.tasks[taskId];
1209
+ if (!task) {
1210
+ sendError(res, 404, "Task not found");
1211
+ return;
1212
+ }
1213
+ sendJson(res, 200, { task: serializeTask(task) });
1214
+ return;
1215
+ }
1216
+
1217
+ // GET /api/v1/tasks/:id/execution
1218
+ if (req.method === "GET" && pathname.match(/^\/api\/v1\/tasks\/[^/]+\/execution$/)) {
1219
+ const taskId = pathname.split("/")[4]!;
1220
+ const state = getTeamState();
1221
+ const task = state?.tasks[taskId];
1222
+ if (!task) {
1223
+ sendError(res, 404, "Task not found");
1224
+ return;
1225
+ }
1226
+
1227
+ const clarifications = state
1228
+ ? Object.values(state.clarifications)
1229
+ .filter((item) => item.taskId === taskId)
1230
+ .sort((left, right) => left.createdAt - right.createdAt)
1231
+ : [];
1232
+ const messages = state
1233
+ ? state.messages
1234
+ .filter((message) => message.taskId === taskId)
1235
+ .sort((left, right) => left.createdAt - right.createdAt)
1236
+ : [];
1237
+
1238
+ sendJson(res, 200, {
1239
+ task: serializeTask(task, true),
1240
+ messages,
1241
+ clarifications,
1242
+ });
1243
+ return;
1244
+ }
1245
+
1246
+ // PATCH /api/v1/tasks/:id
1247
+ if (req.method === "PATCH" && pathname.match(/^\/api\/v1\/tasks\/[^/]+$/)) {
1248
+ const taskId = pathname.split("/").pop()!;
1249
+ const body = await parseJsonBody(req);
1250
+ let statusEvent: TaskExecutionEvent | undefined;
1251
+ let progressEvent: TaskExecutionEvent | undefined;
1252
+
1253
+ const state = updateTeamState((s) => {
1254
+ const task = s.tasks[taskId];
1255
+ if (!task) return;
1256
+ const previousStatus = task.status;
1257
+ const previousProgress = task.progress;
1258
+ if (typeof body.status === "string") task.status = body.status as TaskStatus;
1259
+ if (typeof body.progress === "string") task.progress = body.progress as string;
1260
+ if (typeof body.priority === "string") task.priority = body.priority as TaskPriority;
1261
+ if (typeof body.assignedRole === "string") task.assignedRole = body.assignedRole as RoleId;
1262
+ task.updatedAt = Date.now();
1263
+
1264
+ if (typeof body.status === "string" && body.status !== previousStatus) {
1265
+ statusEvent = appendTaskExecutionEvent(task, {
1266
+ type: "lifecycle",
1267
+ phase: `status_${task.status}`,
1268
+ source: "controller",
1269
+ status: mapTaskStatusToExecutionStatus(task.status, task.execution?.status),
1270
+ message: `Task status updated to ${task.status}.`,
1271
+ });
1272
+ }
1273
+ if (typeof body.progress === "string" && body.progress !== previousProgress) {
1274
+ progressEvent = appendTaskExecutionEvent(task, {
1275
+ type: "progress",
1276
+ phase: "progress_reported",
1277
+ source: "worker",
1278
+ status: task.status === "in_progress" || task.status === "review" ? "running" : undefined,
1279
+ message: body.progress as string,
1280
+ });
1281
+ }
1282
+ });
1283
+
1284
+ const updatedTask = state.tasks[taskId];
1285
+ if (updatedTask) {
1286
+ if (statusEvent) {
1287
+ broadcastTaskExecutionEvent(taskId, updatedTask, statusEvent, deps);
1288
+ }
1289
+ if (progressEvent) {
1290
+ broadcastTaskExecutionEvent(taskId, updatedTask, progressEvent, deps);
1291
+ }
1292
+ wsServer.broadcastUpdate({ type: "task:updated", data: serializeTask(updatedTask) });
1293
+ }
1294
+ sendJson(res, 200, { task: serializeTask(updatedTask) });
1295
+ return;
1296
+ }
1297
+
1298
+ // POST /api/v1/tasks/:id/assign
1299
+ if (req.method === "POST" && pathname.match(/^\/api\/v1\/tasks\/[^/]+\/assign$/)) {
1300
+ const taskId = pathname.split("/")[4]!;
1301
+ const body = await parseJsonBody(req);
1302
+ const workerId = typeof body.workerId === "string" ? body.workerId : undefined;
1303
+ const targetRole = typeof body.targetRole === "string" ? body.targetRole as RoleId : undefined;
1304
+
1305
+ const state = getTeamState();
1306
+ if (!state?.tasks[taskId]) {
1307
+ sendError(res, 404, "Task not found");
1308
+ return;
1309
+ }
1310
+
1311
+ let targetWorker: WorkerInfo | null = null;
1312
+ if (workerId && state.workers[workerId]) {
1313
+ targetWorker = state.workers[workerId]!;
1314
+ } else {
1315
+ const taskForRouting = targetRole
1316
+ ? { ...state.tasks[taskId], assignedRole: targetRole }
1317
+ : state.tasks[taskId];
1318
+ targetWorker = taskRouter.routeTask(taskForRouting, state.workers);
1319
+ }
1320
+
1321
+ if (!targetWorker) {
1322
+ sendError(res, 404, "No available worker for this task");
1323
+ return;
1324
+ }
1325
+
1326
+ const updatedTask = await assignTaskToWorker(taskId, targetWorker, deps, {
1327
+ assignedRole: targetRole,
1328
+ });
1329
+ wsServer.broadcastUpdate({ type: "task:updated", data: serializeTask(updatedTask) });
1330
+ sendJson(res, 200, { task: serializeTask(updatedTask), worker: targetWorker });
1331
+ return;
1332
+ }
1333
+
1334
+ // POST /api/v1/tasks/:id/handoff
1335
+ if (req.method === "POST" && pathname.match(/^\/api\/v1\/tasks\/[^/]+\/handoff$/)) {
1336
+ const taskId = pathname.split("/")[4]!;
1337
+ const body = await parseJsonBody(req);
1338
+ const targetRole = typeof body.targetRole === "string" ? body.targetRole as RoleId : undefined;
1339
+
1340
+ const state = getTeamState();
1341
+ if (!state?.tasks[taskId]) {
1342
+ sendError(res, 404, "Task not found");
1343
+ return;
1344
+ }
1345
+
1346
+ const previousWorkerId = state.tasks[taskId].assignedWorkerId;
1347
+
1348
+ updateTeamState((s) => {
1349
+ s.tasks[taskId].status = "pending";
1350
+ s.tasks[taskId].assignedWorkerId = undefined;
1351
+ s.tasks[taskId].assignedRole = targetRole ?? s.tasks[taskId].assignedRole;
1352
+ s.tasks[taskId].updatedAt = Date.now();
1353
+
1354
+ // Free old worker
1355
+ if (previousWorkerId && s.workers[previousWorkerId]) {
1356
+ s.workers[previousWorkerId].status = "idle";
1357
+ s.workers[previousWorkerId].currentTaskId = undefined;
1358
+ }
1359
+ });
1360
+
1361
+ await cancelTaskExecution(taskId, previousWorkerId, "handoff", deps);
1362
+
1363
+ // Try auto-assign to new role
1364
+ const newState = getTeamState()!;
1365
+ const worker = taskRouter.routeTask(newState.tasks[taskId], newState.workers);
1366
+ if (worker) {
1367
+ await assignTaskToWorker(taskId, worker, deps, { assignedRole: targetRole });
1368
+ }
1369
+
1370
+ const updatedTask = getTeamState()?.tasks[taskId];
1371
+ recordTaskExecutionEvent(taskId, {
1372
+ type: "lifecycle",
1373
+ phase: "handoff",
1374
+ source: "controller",
1375
+ message: targetRole
1376
+ ? `Task handed off and re-routed to role ${targetRole}.`
1377
+ : "Task handed off for re-routing.",
1378
+ role: targetRole,
1379
+ }, deps);
1380
+ wsServer.broadcastUpdate({ type: "task:updated", data: serializeTask(updatedTask) });
1381
+ sendJson(res, 200, { task: serializeTask(updatedTask) });
1382
+ return;
1383
+ }
1384
+
1385
+ // POST /api/v1/tasks/:id/result
1386
+ if (req.method === "POST" && pathname.match(/^\/api\/v1\/tasks\/[^/]+\/result$/)) {
1387
+ const taskId = pathname.split("/")[4]!;
1388
+ const body = await parseJsonBody(req);
1389
+ const result = typeof body.result === "string" ? body.result : "";
1390
+ const error = typeof body.error === "string" ? body.error : undefined;
1391
+ const workerId = typeof body.workerId === "string" ? body.workerId : undefined;
1392
+ const currentTask = getTeamState()?.tasks[taskId];
1393
+ if (!currentTask) {
1394
+ sendError(res, 404, "Task not found");
1395
+ return;
1396
+ }
1397
+ if (workerId && !canAcceptWorkerUpdate(currentTask, workerId)) {
1398
+ logger.info(`Controller: ignoring stale task result for ${taskId} from ${workerId}`);
1399
+ sendJson(res, 202, { status: "ignored", reason: "stale-worker-result" });
1400
+ return;
1401
+ }
1402
+ const previousWorkerId = getTeamState()?.tasks[taskId]?.assignedWorkerId;
1403
+
1404
+ const updatedTask = applyTaskResult(taskId, result, error, deps);
1405
+ if (!workerId || workerId !== previousWorkerId) {
1406
+ await cancelTaskExecution(taskId, previousWorkerId, "manual result submission", deps);
1407
+ }
1408
+ sendJson(res, 200, { task: serializeTask(updatedTask) });
1409
+ return;
1410
+ }
1411
+
1412
+ // POST /api/v1/tasks/:id/execution
1413
+ if (req.method === "POST" && pathname.match(/^\/api\/v1\/tasks\/[^/]+\/execution$/)) {
1414
+ const taskId = pathname.split("/")[4]!;
1415
+ const body = await parseJsonBody(req);
1416
+ const type = typeof body.type === "string" ? body.type : "";
1417
+ const message = typeof body.message === "string" ? body.message : "";
1418
+ const workerId = typeof body.workerId === "string" ? body.workerId : undefined;
1419
+ const currentTask = getTeamState()?.tasks[taskId];
1420
+
1421
+ if (!type || !message) {
1422
+ sendError(res, 400, "type and message are required");
1423
+ return;
1424
+ }
1425
+
1426
+ if (!currentTask) {
1427
+ sendError(res, 404, "Task not found");
1428
+ return;
1429
+ }
1430
+
1431
+ if (workerId && !canAcceptWorkerUpdate(currentTask, workerId)) {
1432
+ logger.info(`Controller: ignoring stale execution event for ${taskId} from ${workerId}`);
1433
+ sendJson(res, 202, { status: "ignored", reason: "stale-worker-event" });
1434
+ return;
1435
+ }
1436
+
1437
+ const recorded = recordTaskExecutionEvent(taskId, {
1438
+ type: type as TaskExecutionEventInput["type"],
1439
+ message,
1440
+ createdAt: typeof body.createdAt === "number" ? body.createdAt : undefined,
1441
+ phase: typeof body.phase === "string" ? body.phase : undefined,
1442
+ source: typeof body.source === "string" ? body.source as TaskExecutionEventInput["source"] : undefined,
1443
+ stream: typeof body.stream === "string" ? body.stream : undefined,
1444
+ role: typeof body.role === "string" ? body.role as RoleId : undefined,
1445
+ workerId,
1446
+ runId: typeof body.runId === "string" ? body.runId : undefined,
1447
+ sessionKey: typeof body.sessionKey === "string" ? body.sessionKey : undefined,
1448
+ status: typeof body.status === "string" ? body.status as TaskExecutionEventInput["status"] : undefined,
1449
+ }, deps);
1450
+
1451
+ if (!recorded.task || !recorded.event) {
1452
+ sendError(res, 404, "Task not found");
1453
+ return;
1454
+ }
1455
+
1456
+ sendJson(res, 201, {
1457
+ task: serializeTask(recorded.task),
1458
+ execution: buildTaskExecutionSummary(recorded.task.execution),
1459
+ event: recorded.event,
1460
+ });
1461
+ return;
1462
+ }
1463
+
1464
+ // ==================== Message Routing ====================
1465
+
1466
+ // POST /api/v1/controller/intake
1467
+ if (req.method === "POST" && pathname === "/api/v1/controller/intake") {
1468
+ const body = await parseJsonBody(req);
1469
+ const message = typeof body.message === "string" ? body.message.trim() : "";
1470
+ if (!message) {
1471
+ sendError(res, 400, "message is required");
1472
+ return;
1473
+ }
1474
+
1475
+ const sessionKey = normalizeControllerIntakeSessionKey(body.sessionKey);
1476
+
1477
+ try {
1478
+ const result = await runControllerIntake(message, sessionKey, deps);
1479
+ sendJson(res, 200, result);
1480
+ } catch (err) {
1481
+ const errorMessage = err instanceof Error ? err.message : String(err);
1482
+ logger.warn(`Controller: intake failed for ${sessionKey}: ${errorMessage}`);
1483
+ sendError(res, errorMessage.includes("timed out") ? 504 : 500, errorMessage);
1484
+ }
1485
+ return;
1486
+ }
1487
+
1488
+ // POST /api/v1/messages/direct
1489
+ if (req.method === "POST" && pathname === "/api/v1/messages/direct") {
1490
+ const body = await parseJsonBody(req);
1491
+ const message: TeamMessage = {
1492
+ id: generateId(),
1493
+ from: typeof body.from === "string" ? body.from : "",
1494
+ fromRole: typeof body.fromRole === "string" ? body.fromRole as RoleId : undefined,
1495
+ toRole: typeof body.toRole === "string" ? body.toRole as RoleId : undefined,
1496
+ type: "direct",
1497
+ content: typeof body.content === "string" ? body.content : "",
1498
+ taskId: typeof body.taskId === "string" ? body.taskId : undefined,
1499
+ createdAt: Date.now(),
1500
+ };
1501
+
1502
+ updateTeamState((s) => { s.messages.push(message); });
1503
+
1504
+ const routed = await routeDirectMessage(message, deps);
1505
+
1506
+ wsServer.broadcastUpdate({ type: "message:new", data: message });
1507
+ sendJson(res, 201, { status: routed ? "delivered" : "no-target", message });
1508
+ return;
1509
+ }
1510
+
1511
+ // POST /api/v1/messages/broadcast
1512
+ if (req.method === "POST" && pathname === "/api/v1/messages/broadcast") {
1513
+ const body = await parseJsonBody(req);
1514
+ const message: TeamMessage = {
1515
+ id: generateId(),
1516
+ from: typeof body.from === "string" ? body.from : "",
1517
+ fromRole: typeof body.fromRole === "string" ? body.fromRole as RoleId : undefined,
1518
+ type: "broadcast",
1519
+ content: typeof body.content === "string" ? body.content : "",
1520
+ taskId: typeof body.taskId === "string" ? body.taskId : undefined,
1521
+ createdAt: Date.now(),
1522
+ };
1523
+
1524
+ updateTeamState((s) => { s.messages.push(message); });
1525
+
1526
+ const state = getTeamState()!;
1527
+ const routed = messageRouter.routeBroadcast(message, state.workers);
1528
+ for (const { worker, message: routedMsg } of routed) {
1529
+ try {
1530
+ await deliverMessageToWorker(worker, routedMsg, deps);
1531
+ } catch (err) {
1532
+ logger.warn(`Controller: failed to broadcast to ${worker.id}: ${String(err)}`);
1533
+ }
1534
+ }
1535
+
1536
+ wsServer.broadcastUpdate({ type: "message:new", data: message });
1537
+ sendJson(res, 201, { status: "broadcast", recipients: routed.length });
1538
+ return;
1539
+ }
1540
+
1541
+ // POST /api/v1/messages/review-request
1542
+ if (req.method === "POST" && pathname === "/api/v1/messages/review-request") {
1543
+ const body = await parseJsonBody(req);
1544
+ const message: TeamMessage = {
1545
+ id: generateId(),
1546
+ from: typeof body.from === "string" ? body.from : "",
1547
+ fromRole: typeof body.fromRole === "string" ? body.fromRole as RoleId : undefined,
1548
+ toRole: typeof body.toRole === "string" ? body.toRole as RoleId : undefined,
1549
+ type: "review-request",
1550
+ content: typeof body.content === "string" ? body.content : "",
1551
+ taskId: typeof body.taskId === "string" ? body.taskId : undefined,
1552
+ createdAt: Date.now(),
1553
+ };
1554
+
1555
+ updateTeamState((s) => { s.messages.push(message); });
1556
+
1557
+ const state = getTeamState()!;
1558
+ const routed = messageRouter.routeReviewRequest(message, state.workers);
1559
+ if (routed) {
1560
+ try {
1561
+ await deliverMessageToWorker(routed.worker, routed.message, deps);
1562
+ } catch (err) {
1563
+ logger.warn(`Controller: failed to deliver review request: ${String(err)}`);
1564
+ }
1565
+ }
1566
+
1567
+ wsServer.broadcastUpdate({ type: "message:new", data: message });
1568
+ sendJson(res, 201, { status: routed ? "delivered" : "no-target", message });
1569
+ return;
1570
+ }
1571
+
1572
+ // GET /api/v1/messages
1573
+ if (req.method === "GET" && pathname === "/api/v1/messages") {
1574
+ const state = getTeamState();
1575
+ const messages = state?.messages ?? [];
1576
+ const limit = parseInt(requestUrl.searchParams.get("limit") ?? "50", 10);
1577
+ const offset = parseInt(requestUrl.searchParams.get("offset") ?? "0", 10);
1578
+ sendJson(res, 200, {
1579
+ messages: messages.slice(offset, offset + limit),
1580
+ total: messages.length,
1581
+ });
1582
+ return;
1583
+ }
1584
+
1585
+ // ==================== Clarification Requests ====================
1586
+
1587
+ // POST /api/v1/clarifications
1588
+ if (req.method === "POST" && pathname === "/api/v1/clarifications") {
1589
+ const body = await parseJsonBody(req);
1590
+ const taskId = typeof body.taskId === "string" ? body.taskId : "";
1591
+ const requestedBy = typeof body.requestedBy === "string" ? body.requestedBy : "";
1592
+ const requestedByWorkerId = typeof body.requestedByWorkerId === "string" ? body.requestedByWorkerId : undefined;
1593
+ const requestedByRole = typeof body.requestedByRole === "string" ? body.requestedByRole as RoleId : undefined;
1594
+ const question = typeof body.question === "string" ? body.question.trim() : "";
1595
+ const blockingReason = typeof body.blockingReason === "string" ? body.blockingReason.trim() : "";
1596
+ const context = typeof body.context === "string" && body.context.trim() ? body.context.trim() : undefined;
1597
+
1598
+ if (!taskId || !question || !blockingReason) {
1599
+ sendError(res, 400, "taskId, question, and blockingReason are required");
1600
+ return;
1601
+ }
1602
+
1603
+ const currentState = getTeamState();
1604
+ const currentTask = currentState?.tasks[taskId];
1605
+ if (!currentTask) {
1606
+ sendError(res, 404, "Task not found");
1607
+ return;
1608
+ }
1609
+
1610
+ if (currentTask.clarificationRequestId) {
1611
+ const existing = currentState?.clarifications[currentTask.clarificationRequestId];
1612
+ if (existing?.status === "pending") {
1613
+ sendJson(res, 200, { clarification: existing, task: currentTask, status: "already-pending" });
1614
+ return;
1615
+ }
1616
+ }
1617
+
1618
+ if (currentTask.status === "completed" || currentTask.status === "failed") {
1619
+ sendError(res, 409, "Cannot request clarification for a completed task");
1620
+ return;
1621
+ }
1622
+
1623
+ const previousWorkerId = currentTask.assignedWorkerId;
1624
+
1625
+ const clarificationId = generateId();
1626
+ const now = Date.now();
1627
+ const clarification: ClarificationRequest = {
1628
+ id: clarificationId,
1629
+ taskId,
1630
+ requestedBy,
1631
+ requestedByWorkerId,
1632
+ requestedByRole,
1633
+ question,
1634
+ blockingReason,
1635
+ context,
1636
+ status: "pending",
1637
+ createdAt: now,
1638
+ updatedAt: now,
1639
+ };
1640
+
1641
+ const state = updateTeamState((s) => {
1642
+ s.clarifications[clarificationId] = clarification;
1643
+ const task = s.tasks[taskId];
1644
+ if (!task) {
1645
+ return;
1646
+ }
1647
+
1648
+ const assignedWorkerId = task.assignedWorkerId;
1649
+ task.status = "blocked";
1650
+ task.progress = `Awaiting clarification: ${question}`;
1651
+ task.clarificationRequestId = clarificationId;
1652
+ task.assignedWorkerId = undefined;
1653
+ task.updatedAt = now;
1654
+
1655
+ if (assignedWorkerId && s.workers[assignedWorkerId]) {
1656
+ s.workers[assignedWorkerId].status = "idle";
1657
+ s.workers[assignedWorkerId].currentTaskId = undefined;
1658
+ }
1659
+ });
1660
+
1661
+ await cancelTaskExecution(taskId, previousWorkerId, "clarification request", deps);
1662
+
1663
+ const updatedTask = state.tasks[taskId];
1664
+ wsServer.broadcastUpdate({ type: "clarification:requested", data: clarification });
1665
+ if (updatedTask) {
1666
+ recordTaskExecutionEvent(taskId, {
1667
+ type: "lifecycle",
1668
+ phase: "clarification_requested",
1669
+ source: "controller",
1670
+ message: `Clarification requested: ${question}`,
1671
+ role: clarification.requestedByRole,
1672
+ workerId: clarification.requestedByWorkerId,
1673
+ }, deps);
1674
+ wsServer.broadcastUpdate({ type: "task:updated", data: serializeTask(updatedTask) });
1675
+ }
1676
+ sendJson(res, 201, { clarification, task: serializeTask(updatedTask) });
1677
+ return;
1678
+ }
1679
+
1680
+ // GET /api/v1/clarifications
1681
+ if (req.method === "GET" && pathname === "/api/v1/clarifications") {
1682
+ const state = getTeamState();
1683
+ const clarifications = state
1684
+ ? Object.values(state.clarifications).sort((left, right) => right.createdAt - left.createdAt)
1685
+ : [];
1686
+ sendJson(res, 200, {
1687
+ clarifications,
1688
+ pendingCount: clarifications.filter((item) => item.status === "pending").length,
1689
+ });
1690
+ return;
1691
+ }
1692
+
1693
+ // POST /api/v1/clarifications/:id/answer
1694
+ if (req.method === "POST" && pathname.match(/^\/api\/v1\/clarifications\/[^/]+\/answer$/)) {
1695
+ const clarificationId = pathname.split("/")[4]!;
1696
+ const body = await parseJsonBody(req);
1697
+ const answer = typeof body.answer === "string" ? body.answer.trim() : "";
1698
+ const answeredBy = typeof body.answeredBy === "string" && body.answeredBy.trim()
1699
+ ? body.answeredBy.trim()
1700
+ : "human";
1701
+
1702
+ if (!answer) {
1703
+ sendError(res, 400, "answer is required");
1704
+ return;
1705
+ }
1706
+
1707
+ const currentState = getTeamState();
1708
+ const currentClarification = currentState?.clarifications[clarificationId];
1709
+ if (!currentClarification) {
1710
+ sendError(res, 404, "Clarification request not found");
1711
+ return;
1712
+ }
1713
+
1714
+ if (currentClarification.status === "answered") {
1715
+ sendError(res, 409, "Clarification request already answered");
1716
+ return;
1717
+ }
1718
+
1719
+ const now = Date.now();
1720
+ const state = updateTeamState((s) => {
1721
+ const clarification = s.clarifications[clarificationId];
1722
+ if (!clarification) {
1723
+ return;
1724
+ }
1725
+
1726
+ clarification.status = "answered";
1727
+ clarification.answer = answer;
1728
+ clarification.answeredBy = answeredBy;
1729
+ clarification.answeredAt = now;
1730
+ clarification.updatedAt = now;
1731
+
1732
+ const task = s.tasks[clarification.taskId];
1733
+ if (!task) {
1734
+ return;
1735
+ }
1736
+
1737
+ task.status = "pending";
1738
+ task.progress = `Clarification answered by ${answeredBy}: ${answer}`;
1739
+ task.clarificationRequestId = undefined;
1740
+ task.updatedAt = now;
1741
+ });
1742
+
1743
+ const clarification = state.clarifications[clarificationId];
1744
+ const task = clarification ? state.tasks[clarification.taskId] : undefined;
1745
+
1746
+ let responseMessage: TeamMessage | undefined;
1747
+ if (clarification?.requestedByRole && task) {
1748
+ responseMessage = {
1749
+ id: generateId(),
1750
+ from: answeredBy,
1751
+ toRole: clarification.requestedByRole,
1752
+ type: "direct",
1753
+ content: `Clarification answer for task ${task.id}: ${answer}`,
1754
+ taskId: task.id,
1755
+ createdAt: now,
1756
+ };
1757
+
1758
+ updateTeamState((s) => {
1759
+ s.messages.push(responseMessage!);
1760
+ });
1761
+ await routeDirectMessage(responseMessage, deps);
1762
+ wsServer.broadcastUpdate({ type: "message:new", data: responseMessage });
1763
+ }
1764
+
1765
+ let resumedTask = task;
1766
+ let resumedWorker: WorkerInfo | null = null;
1767
+ if (task) {
1768
+ const latestState = getTeamState()!;
1769
+ if (clarification?.requestedByWorkerId && latestState.workers[clarification.requestedByWorkerId]?.status === "idle") {
1770
+ resumedWorker = latestState.workers[clarification.requestedByWorkerId]!;
1771
+ } else {
1772
+ resumedWorker = taskRouter.routeTask(task, latestState.workers);
1773
+ }
1774
+
1775
+ if (resumedWorker) {
1776
+ resumedTask = await assignTaskToWorker(task.id, resumedWorker, deps, {
1777
+ assignedRole: task.assignedRole,
1778
+ });
1779
+ }
1780
+ }
1781
+
1782
+ wsServer.broadcastUpdate({ type: "clarification:answered", data: clarification });
1783
+ if (resumedTask) {
1784
+ recordTaskExecutionEvent(resumedTask.id, {
1785
+ type: "lifecycle",
1786
+ phase: "clarification_answered",
1787
+ source: "controller",
1788
+ message: `Clarification answered by ${answeredBy}: ${answer}`,
1789
+ role: clarification?.requestedByRole,
1790
+ workerId: clarification?.requestedByWorkerId,
1791
+ }, deps);
1792
+ wsServer.broadcastUpdate({ type: "task:updated", data: serializeTask(resumedTask) });
1793
+ }
1794
+
1795
+ sendJson(res, 200, {
1796
+ clarification,
1797
+ task: serializeTask(resumedTask),
1798
+ resumedWorker,
1799
+ message: responseMessage,
1800
+ });
1801
+ return;
1802
+ }
1803
+
1804
+ // ==================== Git Collaboration ====================
1805
+
1806
+ // GET /api/v1/repo
1807
+ if (req.method === "GET" && pathname === "/api/v1/repo") {
1808
+ const repo = await refreshControllerRepoState(deps);
1809
+ if (!repo?.enabled) {
1810
+ sendJson(res, 200, { enabled: false });
1811
+ return;
1812
+ }
1813
+
1814
+ sendJson(res, 200, { repo });
1815
+ return;
1816
+ }
1817
+
1818
+ // GET /api/v1/repo/bundle
1819
+ if (req.method === "GET" && pathname === "/api/v1/repo/bundle") {
1820
+ try {
1821
+ const exported = await exportControllerGitBundle(config, logger);
1822
+ res.writeHead(200, {
1823
+ "Content-Type": "application/octet-stream",
1824
+ "Content-Length": exported.data.byteLength,
1825
+ "Content-Disposition": `attachment; filename="${exported.filename}"`,
1826
+ "Access-Control-Allow-Origin": "*",
1827
+ "Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE, OPTIONS",
1828
+ "Access-Control-Allow-Headers": "Content-Type",
1829
+ });
1830
+ res.end(exported.data);
1831
+ } catch (err) {
1832
+ sendError(res, 503, err instanceof Error ? err.message : String(err));
1833
+ }
1834
+ return;
1835
+ }
1836
+
1837
+ // POST /api/v1/repo/import
1838
+ if (req.method === "POST" && pathname === "/api/v1/repo/import") {
1839
+ const body = await readRequestBody(req);
1840
+ if (!body.length) {
1841
+ sendError(res, 400, "bundle body is required");
1842
+ return;
1843
+ }
1844
+
1845
+ const taskId = typeof requestUrl.searchParams.get("taskId") === "string" && requestUrl.searchParams.get("taskId")
1846
+ ? requestUrl.searchParams.get("taskId")!
1847
+ : undefined;
1848
+ const workerId = typeof requestUrl.searchParams.get("workerId") === "string" && requestUrl.searchParams.get("workerId")
1849
+ ? requestUrl.searchParams.get("workerId")!
1850
+ : undefined;
1851
+ const role = typeof requestUrl.searchParams.get("role") === "string" && requestUrl.searchParams.get("role")
1852
+ ? requestUrl.searchParams.get("role") as RoleId
1853
+ : undefined;
1854
+
1855
+ try {
1856
+ const imported = await importControllerGitBundle(config, logger, body, { taskId, workerId });
1857
+ updateTeamState((s) => {
1858
+ s.repo = imported.repo;
1859
+ });
1860
+
1861
+ if (taskId) {
1862
+ recordTaskExecutionEvent(taskId, {
1863
+ type: imported.merged || imported.alreadyUpToDate ? "lifecycle" : "error",
1864
+ phase: imported.merged
1865
+ ? "repo_imported"
1866
+ : imported.alreadyUpToDate
1867
+ ? "repo_import_skipped"
1868
+ : "repo_import_failed",
1869
+ source: "controller",
1870
+ message: imported.message,
1871
+ workerId,
1872
+ role,
1873
+ }, deps);
1874
+ }
1875
+
1876
+ sendJson(res, imported.merged || imported.alreadyUpToDate ? 200 : 409, {
1877
+ repo: imported.repo,
1878
+ merged: imported.merged,
1879
+ fastForwarded: imported.fastForwarded,
1880
+ alreadyUpToDate: imported.alreadyUpToDate,
1881
+ message: imported.message,
1882
+ });
1883
+ } catch (err) {
1884
+ const message = err instanceof Error ? err.message : String(err);
1885
+ if (taskId) {
1886
+ recordTaskExecutionEvent(taskId, {
1887
+ type: "error",
1888
+ phase: "repo_import_failed",
1889
+ source: "controller",
1890
+ message,
1891
+ workerId,
1892
+ role,
1893
+ }, deps);
1894
+ }
1895
+ sendError(res, 500, message);
1896
+ }
1897
+ return;
1898
+ }
1899
+
1900
+ // ==================== Team Info ====================
1901
+
1902
+ // GET /api/v1/team/status
1903
+ if (req.method === "GET" && pathname === "/api/v1/team/status") {
1904
+ const state = getTeamState();
1905
+ if (!state) {
1906
+ sendJson(res, 200, {
1907
+ teamName: config.teamName,
1908
+ workers: [],
1909
+ tasks: [],
1910
+ messages: [],
1911
+ clarifications: [],
1912
+ repo: null,
1913
+ pendingClarificationCount: 0,
1914
+ });
1915
+ return;
1916
+ }
1917
+
1918
+ const clarifications = Object.values(state.clarifications).sort((left, right) => right.createdAt - left.createdAt);
1919
+ sendJson(res, 200, {
1920
+ teamName: state.teamName,
1921
+ workers: Object.values(state.workers),
1922
+ tasks: Object.values(state.tasks).map((task) => serializeTask(task)),
1923
+ messages: state.messages,
1924
+ clarifications,
1925
+ repo: state.repo ?? null,
1926
+ taskCount: Object.keys(state.tasks).length,
1927
+ workerCount: Object.keys(state.workers).length,
1928
+ pendingClarificationCount: clarifications.filter((item) => item.status === "pending").length,
1929
+ });
1930
+ return;
1931
+ }
1932
+
1933
+ // GET /api/v1/roles
1934
+ if (req.method === "GET" && pathname === "/api/v1/roles") {
1935
+ sendJson(res, 200, { roles: ROLES });
1936
+ return;
1937
+ }
1938
+
1939
+ // GET /api/v1/health
1940
+ if (req.method === "GET" && pathname === "/api/v1/health") {
1941
+ sendJson(res, 200, { status: "ok", mode: "controller", timestamp: Date.now() });
1942
+ return;
1943
+ }
1944
+
1945
+ sendError(res, 404, "Not found");
1946
+ }