import fs from "node:fs"; import path from "node:path"; import http from "node:http"; import zlib from "node:zlib"; import type { IncomingMessage, ServerResponse } from "node:http"; import type { OpenClawPluginApi, PluginLogger } from "../../api.js"; import type { ClarificationRequest, ControllerOrchestrationManifest, ControllerRunInfo, ControllerRunSource, GitRepoState, PluginConfig, RepoSyncInfo, RoleId, StartupProvisioningReadiness, TaskExecution, TaskExecutionEvent, TaskExecutionEventInput, TaskAssignmentPayload, TaskExecutionSummary, TaskInfo, TaskPriority, TaskStatus, TeamMessage, TeamState, WorkerProgressContract, WorkerInfo, WorkerTaskResultContract, WorkerTaskResultDeliverable, } from "../types.js"; import { parseJsonBody, readRequestBody, sendJson, sendError, generateId, } from "../protocol.js"; import { listWorkspaceTree, listWorkspaceSubtree, readWorkspaceFile, readWorkspaceRawFile } from "../workspace-browser.js"; import { ROLES, normalizeRecommendedSkills, resolveRecommendedSkillsForRole } from "../roles.js"; import { buildRepoSyncInfo, ensureControllerGitRepo, exportControllerGitBundle, importControllerGitBundle } from "../git-collaboration.js"; import { TaskRouter } from "./task-router.js"; import { MessageRouter } from "./message-router.js"; import { TeamWebSocketServer } from "./websocket.js"; import type { WorkerProvisioningManager } from "./worker-provisioning.js"; import type { PreviewManager } from "./preview-manager.js"; import { createControllerPromptInjector } from "./prompt-injector.js"; import { buildControllerNoWorkersMessage, shouldBlockControllerWithoutWorkers } from "./controller-capacity.js"; import { generateDeliveryReport, isSessionComplete, renderReportHtml, type DeliveryReport } from "./delivery-report.js"; import { backfillWorkerProgressContract, backfillWorkerTaskResultContract, ensureTeamMessageContract, normalizeTaskHandoffContract, normalizeWorkerProgressContract, normalizeWorkerTaskResultContract, enrichDeliverablesWithPreviewInference, } from "../interaction-contracts.js"; import { buildTeamClawProjectWorkspacePath, buildTeamClawAgentSessionKey, getTeamClawModelReadiness, resolveTeamClawAgentWorkspaceRootDir, resolveTeamClawWorkspaceDir, resolveTeamClawProjectsDir, deriveStableProjectKey, deriveProjectSlug, } from "../openclaw-workspace.js"; import { resolvePreferredLanAddress } from "../networking.js"; import { normalizeClarificationQuestionSchema, normalizeControllerManifest } from "./orchestration-manifest.js"; import type { KickoffHandler } from "./controller-service.js"; export type ControllerHttpDeps = { config: PluginConfig; logger: PluginLogger; runtime: OpenClawPluginApi["runtime"]; getTeamState: () => TeamState | null; updateTeamState: (updater: (state: TeamState) => void) => TeamState; taskRouter: TaskRouter; messageRouter: MessageRouter; wsServer: TeamWebSocketServer; workerProvisioningManager?: WorkerProvisioningManager | null; previewManager?: PreviewManager; /** Late-bound kickoff handler for automatic team kickoff on complex projects. */ getKickoffHandler?: () => KickoffHandler | undefined; }; const MAX_TASK_EXECUTION_EVENTS = 250; const MAX_CONTROLLER_RUNS = 40; const MAX_RECENT_TASK_CONTEXT = 3; const MAX_TASK_CONTEXT_SUMMARY_CHARS = 500; const CONTROLLER_INTAKE_SESSION_PREFIX = "teamclaw-controller-web:"; const CONTROLLER_INTAKE_AGENT_SESSION_RE = /^agent:[^:]+:(teamclaw-controller-web:[a-zA-Z0-9:_-]{1,120})$/; const CONTROLLER_RUN_WAIT_SLICE_MS = 30_000; const EXISTING_PROJECT_REUSE_HINT_RE = /\b(existing|optimi(?:s|z)e|optimi(?:s|z)ation|improvement|enhanc(?:e|ement)|follow[- ]?up|extend|update|bugfix|bug fix)\b/iu; const GENERIC_PROJECT_FOLLOW_UP_RE = /\b(continue|continuing|next step|what else|what's next|follow[- ]?up|more|remaining)\b|继续|接下来|下一步|还有哪些|还可以做什么/u; const PROJECT_MATCH_STOPWORDS = new Set([ "a", "an", "and", "app", "application", "build", "complete", "existing", "follow", "for", "hub", "improvement", "improvements", "internal", "optimization", "product", "project", "requirement", "request", "studio", "system", "the", "this", "up", "update", "web", ]); const CONTROLLER_RATE_LIMIT_STALL_PROBE_MS = 5 * 60 * 1000; const CONTROLLER_RATE_LIMIT_PROBE_TIMEOUT_MS = 60_000; const CONTROLLER_INACTIVITY_PROBE_TIMEOUT_MS = 60_000; const CONTROLLER_RATE_LIMIT_WAITING_SENTINEL = "TEAMCLAW_STILL_WAITING"; const CONTROLLER_INTAKE_MAX_RETRIES = 2; const CONTROLLER_INTAKE_RETRY_DELAY_MS = 3_000; const CONTROLLER_INTAKE_RETRYABLE_ERROR_PATTERN = /(500|502|503|server error|internal error|overloaded|unavailable)/i; const controllerIntakeQueue = new Map>(); const EXTERNAL_WORKER_INSTALL_SPEC = "@teamclaws/teamclaw"; export function buildControllerIntakeSystemPrompt( deps: Pick, ): string { const injector = createControllerPromptInjector({ config: deps.config, getTeamState: deps.getTeamState, }); return injector()?.prependSystemContext ?? ""; } function resolveRequestPort(req: IncomingMessage, fallbackPort: number): number { const hostHeader = req.headers.host?.trim(); if (!hostHeader) { return fallbackPort; } try { const parsed = new URL(`http://${hostHeader}`); return parsed.port ? Number(parsed.port) : fallbackPort; } catch { return fallbackPort; } } function resolveRecommendedLanControllerUrl(req: IncomingMessage, fallbackPort: number): string { const port = resolveRequestPort(req, fallbackPort); const preferredLan = resolvePreferredLanAddress(); return preferredLan ? `http://${preferredLan}:${port}` : ""; } function shellEscapeSingleQuotes(value: string): string { return `'${String(value).replace(/'/g, `'\\''`)}'`; } function buildExternalWorkerInstallInfo(req: IncomingMessage, config: PluginConfig): Record { const recommendedControllerUrl = resolveRecommendedLanControllerUrl(req, config.port); return { teamName: config.teamName, recommendedControllerUrl, roles: ROLES.map((role) => ({ id: role.id, label: role.label, icon: role.icon })), autoDiscoveryCommandPrefix: `npx -y ${EXTERNAL_WORKER_INSTALL_SPEC} install --yes --install-mode worker --team-name ${shellEscapeSingleQuotes(config.teamName)} --worker-role `, manualCommandPrefix: `npx -y ${EXTERNAL_WORKER_INSTALL_SPEC} install --yes --install-mode worker --team-name ${shellEscapeSingleQuotes(config.teamName)} --worker-role `, manualControllerUrlFlag: recommendedControllerUrl ? ` --controller-url ${shellEscapeSingleQuotes(recommendedControllerUrl)}` : "", manualControllerWarning: recommendedControllerUrl ? "Manual worker installs must use the controller LAN IP, not localhost/127.0.0.1." : "No private LAN IPv4 address was detected on this controller host yet, so manual worker install commands are unavailable until a LAN address exists.", autoDiscoveryWarning: "mDNS auto-discovery only works when the worker and controller are reachable on the same LAN.", }; } function mapTaskStatusToExecutionStatus(taskStatus: TaskStatus, current?: TaskExecution["status"]): TaskExecution["status"] { switch (taskStatus) { case "completed": return "completed"; case "failed": return "failed"; case "in_progress": case "review": return "running"; case "pending": case "assigned": return current ?? "pending"; case "blocked": return current ?? "running"; default: return current ?? "pending"; } } function ensureTaskExecution(task: TaskInfo): TaskExecution { if (!task.execution) { task.execution = { status: mapTaskStatusToExecutionStatus(task.status), startedAt: task.startedAt, endedAt: task.completedAt, lastUpdatedAt: task.updatedAt, events: [], }; } if (!Array.isArray(task.execution.events)) { task.execution.events = []; } task.execution.status = task.execution.status ?? mapTaskStatusToExecutionStatus(task.status); task.execution.startedAt = task.execution.startedAt ?? task.startedAt; task.execution.endedAt = task.execution.endedAt ?? task.completedAt; task.execution.lastUpdatedAt = task.execution.lastUpdatedAt ?? task.updatedAt; return task.execution; } function resetTaskForFreshAttempt(task: TaskInfo): void { delete task.startedAt; delete task.completedAt; delete task.result; delete task.error; delete task.resultContract; if (task.execution) { task.execution.status = "pending"; delete task.execution.runId; delete task.execution.sessionKey; delete task.execution.startedAt; delete task.execution.endedAt; task.execution.lastUpdatedAt = Date.now(); } } function buildTaskExecutionIdentity(taskId: string, workerId: string): { executionSessionKey: string; executionIdempotencyKey: string; } { const attemptId = generateId(); return { executionSessionKey: buildTeamClawAgentSessionKey(`teamclaw-task-${taskId}-${attemptId}`), executionIdempotencyKey: `teamclaw-${taskId}-${workerId}-${attemptId}`, }; } function appendTaskExecutionEvent(task: TaskInfo, input: TaskExecutionEventInput): TaskExecutionEvent { const now = input.createdAt ?? Date.now(); const execution = ensureTaskExecution(task); if (input.runId) { execution.runId = input.runId; } if (input.sessionKey) { execution.sessionKey = input.sessionKey; } if (input.status) { execution.status = input.status; } else { execution.status = mapTaskStatusToExecutionStatus(task.status, execution.status); } if ((input.status === "running" || input.phase === "run_started") && !execution.startedAt) { execution.startedAt = now; } if ((input.status === "running" || input.phase === "run_started") && !task.startedAt) { task.startedAt = now; } if ((input.status === "running" || input.phase === "run_started") && (task.status === "pending" || task.status === "assigned")) { task.status = "in_progress"; } if (execution.status === "completed" || execution.status === "failed") { execution.endedAt = execution.endedAt ?? now; } execution.lastUpdatedAt = now; task.updatedAt = now; const event: TaskExecutionEvent = { id: generateId(), type: input.type, createdAt: now, message: input.message, phase: input.phase, source: input.source, stream: input.stream, role: input.role ?? task.assignedRole, workerId: input.workerId ?? task.assignedWorkerId, }; execution.events.push(event); if (execution.events.length > MAX_TASK_EXECUTION_EVENTS) { execution.events = execution.events.slice(-MAX_TASK_EXECUTION_EVENTS); } return event; } function buildTaskExecutionSummary(execution?: TaskExecution): TaskExecutionSummary | undefined { if (!execution) { return undefined; } return { status: execution.status, runId: execution.runId, startedAt: execution.startedAt, endedAt: execution.endedAt, lastUpdatedAt: execution.lastUpdatedAt, eventCount: execution.events.length, lastEvent: execution.events[execution.events.length - 1], }; } function ensureControllerRunExecution(run: ControllerRunInfo): TaskExecution { if (!run.execution) { run.execution = { status: run.status, runId: run.runId, startedAt: run.startedAt, endedAt: run.completedAt, lastUpdatedAt: run.updatedAt, events: [], }; } if (!Array.isArray(run.execution.events)) { run.execution.events = []; } run.execution.status = run.status; run.execution.runId = run.runId ?? run.execution.runId; run.execution.startedAt = run.startedAt ?? run.execution.startedAt; run.execution.endedAt = run.completedAt ?? run.execution.endedAt; run.execution.lastUpdatedAt = run.updatedAt ?? run.execution.lastUpdatedAt; return run.execution; } function appendControllerRunEvent(run: ControllerRunInfo, input: TaskExecutionEventInput): TaskExecutionEvent { const now = input.createdAt ?? Date.now(); const execution = ensureControllerRunExecution(run); if (input.runId) { run.runId = input.runId; execution.runId = input.runId; } if (input.sessionKey) { execution.sessionKey = input.sessionKey; } if (input.status) { run.status = input.status; execution.status = input.status; } if ((input.status === "running" || input.phase === "run_started") && !run.startedAt) { run.startedAt = now; execution.startedAt = now; } if (run.status === "completed" || run.status === "failed") { run.completedAt = run.completedAt ?? now; execution.endedAt = execution.endedAt ?? now; } run.updatedAt = now; execution.lastUpdatedAt = now; const event: TaskExecutionEvent = { id: generateId(), type: input.type, createdAt: now, message: input.message, phase: input.phase, source: input.source, stream: input.stream, }; execution.events.push(event); if (execution.events.length > MAX_TASK_EXECUTION_EVENTS) { execution.events = execution.events.slice(-MAX_TASK_EXECUTION_EVENTS); } return event; } function trimControllerRuns(state: TeamState): void { const runs = Object.values(state.controllerRuns) .sort((left, right) => left.updatedAt - right.updatedAt); if (runs.length <= MAX_CONTROLLER_RUNS) { return; } for (const run of runs.slice(0, runs.length - MAX_CONTROLLER_RUNS)) { delete state.controllerRuns[run.id]; } } function serializeControllerRun(run?: ControllerRunInfo, includeExecutionEvents = true): Record | undefined { if (!run) { return undefined; } const payload: Record = { ...run }; if (!run.execution) { return payload; } payload.execution = includeExecutionEvents ? { status: run.execution.status, runId: run.execution.runId, startedAt: run.execution.startedAt, endedAt: run.execution.endedAt, lastUpdatedAt: run.execution.lastUpdatedAt, events: run.execution.events.map((event) => ({ ...event })), } : buildTaskExecutionSummary(run.execution); return payload; } function buildControllerRunTitle( message: string, source: ControllerRunSource, sourceTaskTitle?: string, ): string { if (source === "task_follow_up") { return sourceTaskTitle ? `Controller follow-up after ${sourceTaskTitle}` : "Controller workflow follow-up"; } const normalized = message.replace(/\s+/g, " ").trim(); if (!normalized) { return "Controller intake"; } if (normalized.length <= 100) { return normalized; } return `${normalized.slice(0, 100).trimEnd()}…`; } function createControllerRun( message: string, sessionKey: string, deps: ControllerHttpDeps, options?: { source?: ControllerRunSource; sourceTaskId?: string; sourceTaskTitle?: string; }, ): ControllerRunInfo { const now = Date.now(); const existingState = deps.getTeamState(); const inheritedProject = options?.sourceTaskId ? resolveProjectIdentityForTaskId(options.sourceTaskId, existingState) : resolveProjectIdentityForSession(sessionKey, existingState) ?? resolveExistingProjectIdentityFromMessage(message, existingState); const explicitProjectId = extractExplicitProjectNameHint(message); const projectId = inheritedProject?.projectId ?? explicitProjectId; const projectDir = inheritedProject?.projectDir ?? projectId ?? deriveProjectSlug(message); const run: ControllerRunInfo = { id: generateId(), title: buildControllerRunTitle(message, options?.source ?? "human", options?.sourceTaskTitle), sessionKey, projectId: projectId || undefined, projectDir, source: options?.source ?? "human", sourceTaskId: options?.sourceTaskId, sourceTaskTitle: options?.sourceTaskTitle, request: message, createdTaskIds: [], status: "pending", createdAt: now, updatedAt: now, }; const state = deps.updateTeamState((teamState) => { syncProjectRegistryEntry(teamState, { projectId: run.projectId, projectDir: run.projectDir, aliases: [run.projectId, extractExplicitProjectNameHint(message)], summary: run.title, updatedAt: now, }); teamState.controllerRuns[run.id] = run; trimControllerRuns(teamState); }); const createdRun = state.controllerRuns[run.id] ?? run; deps.wsServer.broadcastUpdate({ type: "controller:run", data: serializeControllerRun(createdRun) }); return createdRun; } function resolveExistingProjectIdentityFromMessage( message: string, state: TeamState | null, ): { projectId?: string; projectDir?: string } | undefined { const normalizedMessage = normalizeProjectMatchingText(message); if (!normalizedMessage) { return undefined; } const explicitAlias = normalizeProjectMatchingText(extractExplicitProjectNameHint(message) ?? ""); let best: { projectId?: string; projectDir: string; score: number; updatedAt: number } | null = null; const candidates = collectExistingProjectCandidates(state); for (const candidate of candidates) { const normalizedAliases = Array.from(candidate.aliases) .map((alias) => normalizeProjectMatchingText(alias)) .filter(Boolean) .reduce((acc, alias) => { acc.push(alias); return acc; }, []); const hasExplicitAliasMatch = explicitAlias ? normalizedAliases.some((alias) => alias.includes(explicitAlias) || explicitAlias.includes(alias)) : false; if (explicitAlias && !hasExplicitAliasMatch) { continue; } const aliasScore = normalizedAliases .reduce((score, alias) => { if (!alias) { return score; } return normalizedMessage.includes(alias) ? Math.max(score, alias.split(" ").length + 10) : score; }, 0); const tokenScore = scoreProjectTokenOverlap(normalizedMessage, candidate.searchText); const totalScore = Math.max(aliasScore, tokenScore); if (totalScore < 2) { continue; } if (!best || totalScore > best.score || (totalScore === best.score && candidate.updatedAt > best.updatedAt)) { best = { projectId: candidate.projectId, projectDir: candidate.projectDir, score: totalScore, updatedAt: candidate.updatedAt, }; } } if (best) { return { projectId: best.projectId, projectDir: best.projectDir }; } if ((EXISTING_PROJECT_REUSE_HINT_RE.test(message) || GENERIC_PROJECT_FOLLOW_UP_RE.test(message)) && candidates.length === 1) { return { projectId: candidates[0]?.projectId, projectDir: candidates[0]?.projectDir, }; } return undefined; } function collectExistingProjectCandidates(state: TeamState | null): Array<{ projectId?: string; projectDir: string; aliases: Set; searchText: string; updatedAt: number; }> { const byProjectDir = new Map; texts: string[]; updatedAt: number }>(); const addCandidate = ( projectDir: string | undefined, text: string | undefined, updatedAt: number, alias?: string | null, projectId?: string, ) => { if (!projectDir) { return; } const entry = byProjectDir.get(projectDir) ?? { projectId, aliases: new Set(), texts: [], updatedAt: 0 }; if (!entry.projectId && projectId) { entry.projectId = projectId; } if (text && text.trim()) { entry.texts.push(text); } if (alias && alias.trim()) { entry.aliases.add(alias.trim()); } entry.updatedAt = Math.max(entry.updatedAt, updatedAt); byProjectDir.set(projectDir, entry); }; for (const project of Object.values(state?.projects ?? {})) { addCandidate(project.projectDir, project.summary, project.updatedAt, project.id, project.id); for (const alias of project.aliases ?? []) { addCandidate(project.projectDir, alias, project.updatedAt, alias, project.id); } } for (const run of Object.values(state?.controllerRuns ?? {})) { addCandidate(run.projectDir, run.request, run.updatedAt, extractProjectAliasFromText(run.request), run.projectId); addCandidate(run.projectDir, run.title, run.updatedAt, extractProjectAliasFromText(run.title), run.projectId); addCandidate(run.projectDir, run.manifest?.projectName, run.updatedAt, run.manifest?.projectName, run.projectId); } for (const task of Object.values(state?.tasks ?? {})) { addCandidate(task.projectDir, task.title, task.updatedAt, extractProjectAliasFromText(task.title), task.projectId); addCandidate(task.projectDir, task.description, task.updatedAt, extractProjectAliasFromText(task.description), task.projectId); addCandidate(task.projectDir, task.resultContract?.summary, task.updatedAt, null, task.projectId); } return Array.from(byProjectDir.entries()).map(([projectDir, entry]) => ({ projectId: entry.projectId, projectDir, aliases: entry.aliases, searchText: entry.texts.join("\n"), updatedAt: entry.updatedAt, })); } function extractExplicitProjectNameHint(text: string | undefined): string | undefined { const value = String(text ?? ""); const namedMatch = value.match(/(?:品牌|brand|project\s*name|产品名|名字)\s*[::]?\s*[`"'“”]?([a-z][a-z0-9_-]{1,39})[`"'“”]?/iu); if (namedMatch?.[1]) { return deriveStableProjectKey(namedMatch[1]); } const inlineNames = Array.from(value.matchAll(/`([a-z][a-z0-9_-]{1,39})`/giu)) .map((match) => deriveStableProjectKey(match[1])) .filter(Boolean); return inlineNames.length === 1 ? inlineNames[0] : undefined; } function findProjectRecordByDir(state: TeamState | null, projectDir: string | undefined) { if (!state || !projectDir) { return undefined; } return Object.values(state.projects ?? {}).find((project) => project.projectDir === projectDir); } function syncProjectRegistryEntry( state: TeamState, input: { projectId?: string; projectDir?: string; aliases?: Array; summary?: string; updatedAt: number; }, ): { projectId?: string; projectDir?: string } { state.projects = state.projects && typeof state.projects === "object" ? state.projects : {}; let normalizedProjectId = deriveStableProjectKey(input.projectId ?? ""); let existing = normalizedProjectId ? state.projects[normalizedProjectId] : undefined; const byDir = findProjectRecordByDir(state, input.projectDir); if (!existing && byDir) { existing = byDir; } if (!existing && input.projectDir) { normalizedProjectId = normalizedProjectId || deriveStableProjectKey(input.projectDir); if (normalizedProjectId) { existing = { id: normalizedProjectId, projectDir: input.projectDir, aliases: [], createdAt: input.updatedAt, updatedAt: input.updatedAt, lastUsedAt: input.updatedAt, }; state.projects[normalizedProjectId] = existing; } } if (!existing) { return { projectId: normalizedProjectId || undefined, projectDir: input.projectDir }; } if (normalizedProjectId && existing.id !== normalizedProjectId && !state.projects[normalizedProjectId]) { delete state.projects[existing.id]; existing.id = normalizedProjectId; state.projects[normalizedProjectId] = existing; } if (!existing.projectDir && input.projectDir) { existing.projectDir = input.projectDir; } const aliasSet = new Set(existing.aliases ?? []); aliasSet.add(existing.id); if (existing.projectDir) { aliasSet.add(existing.projectDir); } for (const alias of input.aliases ?? []) { const normalizedAlias = deriveStableProjectKey(String(alias ?? "")); if (normalizedAlias) { aliasSet.add(normalizedAlias); } } existing.aliases = Array.from(aliasSet); existing.summary = input.summary || existing.summary; existing.updatedAt = Math.max(existing.updatedAt || 0, input.updatedAt); existing.lastUsedAt = Math.max(existing.lastUsedAt || 0, input.updatedAt); return { projectId: existing.id, projectDir: existing.projectDir }; } function resolveProjectIdentityForSession( sessionKey: string, state: TeamState | null, ): { projectId?: string; projectDir?: string } | undefined { const runId = findLatestControllerRunIdForSession(sessionKey, state, { preferActive: true }); if (!runId) { return undefined; } const run = state?.controllerRuns[runId]; if (!run) { return undefined; } const project = findProjectRecordByDir(state, run.projectDir); return { projectId: run.projectId ?? project?.id, projectDir: run.projectDir ?? project?.projectDir, }; } function resolveProjectIdentityForTaskId( taskId: string, state: TeamState | null, ): { projectId?: string; projectDir?: string } | undefined { const task = state?.tasks?.[taskId]; if (!task) { return undefined; } const project = findProjectRecordByDir(state, task.projectDir); return { projectId: task.projectId ?? project?.id, projectDir: task.projectDir ?? project?.projectDir, }; } function extractProjectAliasFromText(text: string | undefined): string | null { const firstMeaningfulLine = String(text ?? "") .split(/\n+/u) .map((line) => line.replace(/^#+\s*/u, "").trim()) .find(Boolean); if (!firstMeaningfulLine) { return null; } const normalized = firstMeaningfulLine .replace(/^(product|optimization)\s+requirement[::]\s*/iu, "") .replace(/^implement\s+/iu, "") .replace(/^design\s+/iu, "") .replace(/^enhance\s+/iu, "") .replace(/^qa[:\s-]+/iu, "") .replace(/\s+(architecture|application|web app|workflows?|improvements?)$/iu, "") .trim(); if (normalized.length < 4 || normalized.length > 80) { return null; } return normalized; } function normalizeProjectMatchingText(text: string): string { return text .toLowerCase() .replace(/[`*_#:[\]()/\\-]+/gu, " ") .replace(/\s+/gu, " ") .trim(); } function scoreProjectTokenOverlap(message: string, candidateText: string): number { const messageTokens = tokenizeProjectMatchingText(message); if (messageTokens.size === 0) { return 0; } const candidateTokens = tokenizeProjectMatchingText(candidateText); let overlap = 0; for (const token of candidateTokens) { if (messageTokens.has(token)) { overlap += 1; } } return overlap; } function tokenizeProjectMatchingText(text: string): Set { return new Set( normalizeProjectMatchingText(text) .split(" ") .map((token) => token.trim()) .filter((token) => token.length >= 4 && !PROJECT_MATCH_STOPWORDS.has(token)), ); } function updateControllerRun( runId: string, deps: ControllerHttpDeps, updater: (run: ControllerRunInfo) => void, ): ControllerRunInfo | undefined { const state = deps.updateTeamState((teamState) => { const run = teamState.controllerRuns[runId]; if (!run) { return; } updater(run); trimControllerRuns(teamState); }); const updatedRun = state.controllerRuns[runId]; if (updatedRun) { deps.wsServer.broadcastUpdate({ type: "controller:run", data: serializeControllerRun(updatedRun) }); } return updatedRun; } function recordControllerRunEvent( runId: string, input: TaskExecutionEventInput, deps: ControllerHttpDeps, ): ControllerRunInfo | undefined { return updateControllerRun(runId, deps, (run) => { appendControllerRunEvent(run, input); }); } function serializeTask(task?: TaskInfo, includeExecutionEvents = false): Record | undefined { if (!task) { return undefined; } const payload: Record = { ...task }; if (!task.execution) { return payload; } payload.execution = includeExecutionEvents ? { status: task.execution.status, runId: task.execution.runId, startedAt: task.execution.startedAt, endedAt: task.execution.endedAt, lastUpdatedAt: task.execution.lastUpdatedAt, events: task.execution.events.map((event) => ({ ...event })), } : buildTaskExecutionSummary(task.execution); return payload; } function extractLastAssistantText(messages: unknown[]): string { const assistantMessages = messages.filter((message): message is { role?: unknown; content?: unknown } => { if (!message || typeof message !== "object") { return false; } return (message as { role?: unknown }).role === "assistant"; }); const lastAssistant = assistantMessages[assistantMessages.length - 1]; if (!lastAssistant) { return ""; } if (typeof lastAssistant.content === "string") { return lastAssistant.content; } if (Array.isArray(lastAssistant.content)) { const textBlocks = lastAssistant.content .filter((block): block is { type?: unknown; text?: unknown } => { return !!block && typeof block === "object" && (block as { type?: unknown }).type === "text"; }) .map((block) => (typeof block.text === "string" ? block.text : "")) .filter(Boolean); if (textBlocks.length > 0) { return textBlocks.join("\n"); } } return JSON.stringify(lastAssistant); } function formatDuration(timeoutMs: number): string { const totalSeconds = Math.ceil(timeoutMs / 1000); if (totalSeconds % 3600 === 0) { const hours = totalSeconds / 3600; return `${hours} hour${hours === 1 ? "" : "s"}`; } if (totalSeconds % 60 === 0) { const minutes = totalSeconds / 60; return `${minutes} minute${minutes === 1 ? "" : "s"}`; } return `${totalSeconds} second${totalSeconds === 1 ? "" : "s"}`; } function isRateLimitMessage(value: string): boolean { return /(rate[_ ]limit|too many requests|429\b|resource has been exhausted|tokens per day|quota|throttl)/i.test( String(value || ""), ); } function isStillWaitingResponse(value: string): boolean { return value.replace(/\s+/g, " ").trim() === CONTROLLER_RATE_LIMIT_WAITING_SENTINEL; } function normalizeControllerIntakeSessionKey(input: unknown): string { const fallback = `${CONTROLLER_INTAKE_SESSION_PREFIX}default`; if (typeof input !== "string") { return fallback; } const trimmed = input.trim(); if (!trimmed) { return fallback; } const runtimeMatch = trimmed.match(CONTROLLER_INTAKE_AGENT_SESSION_RE); if (trimmed.startsWith("agent:") && !runtimeMatch) { return fallback; } const logicalKey = runtimeMatch?.[1] ?? trimmed; if (!/^[a-zA-Z0-9:_-]{1,120}$/.test(logicalKey)) { return fallback; } const normalizedLogicalKey = logicalKey.startsWith(CONTROLLER_INTAKE_SESSION_PREFIX) ? logicalKey : `${CONTROLLER_INTAKE_SESSION_PREFIX}${logicalKey}`; return buildTeamClawAgentSessionKey(normalizedLogicalKey); } async function withSerializedControllerIntake( sessionKey: string, fn: () => Promise, ): Promise { const normalizedSessionKey = normalizeControllerIntakeSessionKey(sessionKey); const previous = controllerIntakeQueue.get(normalizedSessionKey) ?? Promise.resolve(); let releaseCurrent!: () => void; const current = new Promise((resolve) => { releaseCurrent = resolve; }); controllerIntakeQueue.set(normalizedSessionKey, current); try { await previous; return await fn(); } finally { releaseCurrent(); if (controllerIntakeQueue.get(normalizedSessionKey) === current) { controllerIntakeQueue.delete(normalizedSessionKey); } } } function collectTaskIds(state: TeamState | null): Set { return new Set(Object.keys(state?.tasks ?? {})); } function normalizeControllerTaskMatchText(input: unknown): string { return typeof input === "string" ? input.replace(/\s+/g, " ").trim().toLowerCase() : ""; } function taskMatchesManifestCreatedTask( task: TaskInfo, manifestTask: ControllerOrchestrationManifest["createdTasks"][number], ): boolean { if (normalizeControllerTaskMatchText(task.title) !== normalizeControllerTaskMatchText(manifestTask.title)) { return false; } if (manifestTask.assignedRole && task.assignedRole && manifestTask.assignedRole !== task.assignedRole) { return false; } if (manifestTask.assignedRole && !task.assignedRole) { return false; } return task.createdBy === "controller"; } function scoreControllerTaskBindingCandidate(task: TaskInfo): number { switch (task.status) { case "pending": return 6; case "assigned": return 5; case "in_progress": return 4; case "review": return 3; case "blocked": return 2; case "completed": return 1; case "failed": default: return 0; } } function reconcileControllerManifestTaskBindings( sessionKey: string, createdTaskIds: string[], manifest: ControllerOrchestrationManifest | undefined, deps: ControllerHttpDeps, ): { taskIds: string[]; linkedTaskIds: string[] } { if (!manifest || manifest.createdTasks.length === 0) { return { taskIds: createdTaskIds, linkedTaskIds: [] }; } const normalizedSessionKey = normalizeControllerIntakeSessionKey(sessionKey); const linkedTaskIds: string[] = []; deps.updateTeamState((state) => { const usedTaskIds = new Set(createdTaskIds); for (const manifestTask of manifest.createdTasks) { const existingTaskId = Array.from(usedTaskIds).find((taskId) => { const task = state.tasks[taskId]; return !!task && taskMatchesManifestCreatedTask(task, manifestTask); }); const matchedTask = existingTaskId ? state.tasks[existingTaskId] : Object.values(state.tasks) .filter((task) => !usedTaskIds.has(task.id)) .filter((task) => taskMatchesManifestCreatedTask(task, manifestTask)) .sort((left, right) => { const scoreDelta = scoreControllerTaskBindingCandidate(right) - scoreControllerTaskBindingCandidate(left); if (scoreDelta !== 0) { return scoreDelta; } return right.updatedAt - left.updatedAt; })[0]; if (!matchedTask) { continue; } if (matchedTask.controllerSessionKey !== normalizedSessionKey) { matchedTask.controllerSessionKey = normalizedSessionKey; } if (!usedTaskIds.has(matchedTask.id)) { usedTaskIds.add(matchedTask.id); linkedTaskIds.push(matchedTask.id); } } }); return { taskIds: Array.from(new Set([...createdTaskIds, ...linkedTaskIds])), linkedTaskIds, }; } function tagControllerCreatedTasks( taskIdsBeforeRun: Set, sessionKey: string, deps: ControllerHttpDeps, ): string[] { const normalizedSessionKey = normalizeControllerIntakeSessionKey(sessionKey); const taggedTaskIds: string[] = []; deps.updateTeamState((state) => { for (const task of Object.values(state.tasks)) { if (taskIdsBeforeRun.has(task.id)) { continue; } if (task.createdBy !== "controller") { continue; } if (!task.controllerSessionKey) { task.controllerSessionKey = normalizedSessionKey; } if (normalizeControllerIntakeSessionKey(task.controllerSessionKey) !== normalizedSessionKey) { continue; } taggedTaskIds.push(task.id); } }); return taggedTaskIds; } function isActiveControllerRun(run: ControllerRunInfo): boolean { return run.status === "pending" || run.status === "running"; } function findLatestControllerRunIdForSession( sessionKey: string, state: TeamState | null, options?: { preferActive?: boolean }, ): string | null { const normalizedSessionKey = normalizeControllerIntakeSessionKey(sessionKey); const matchingRuns = Object.values(state?.controllerRuns ?? {}) .filter((run) => normalizeControllerIntakeSessionKey(run.sessionKey) === normalizedSessionKey) .sort((left, right) => { if (options?.preferActive) { const leftScore = isActiveControllerRun(left) ? 1 : 0; const rightScore = isActiveControllerRun(right) ? 1 : 0; if (leftScore !== rightScore) { return rightScore - leftScore; } } return right.updatedAt - left.updatedAt; }); return matchingRuns[0]?.id ?? null; } function resolveProjectDirForSession( sessionKey: string, state: TeamState | null, ): string | undefined { return resolveProjectIdentityForSession(sessionKey, state)?.projectDir; } function resolveControllerWorkflowSessionKey(task: TaskInfo, state: TeamState | null): string | undefined { if (task.controllerSessionKey) { return normalizeControllerIntakeSessionKey(task.controllerSessionKey); } if (!state || task.createdBy !== "controller") { return undefined; } const sortedRuns = Object.values(state.controllerRuns) .sort((left, right) => right.updatedAt - left.updatedAt); const directRun = sortedRuns.find((run) => run.sourceTaskId === task.id || run.createdTaskIds.includes(task.id), ); if (directRun) { return normalizeControllerIntakeSessionKey(directRun.sessionKey); } const manifestRun = sortedRuns.find((run) => run.manifest?.createdTasks.some((manifestTask) => taskMatchesManifestCreatedTask(task, manifestTask)), ); return manifestRun ? normalizeControllerIntakeSessionKey(manifestRun.sessionKey) : undefined; } function buildControllerManifestEventMessage(manifest: ControllerOrchestrationManifest): string { const parts = [ `Structured orchestration manifest recorded.`, `roles=${manifest.requiredRoles.join(", ") || "none"}`, `created=${manifest.createdTasks.length}`, `deferred=${manifest.deferredTasks.length}`, ]; if (manifest.clarificationsNeeded) { parts.push(`clarifications=${manifest.clarificationQuestions.length}`); } if (manifest.requirementFullyComplete) { parts.push("requirementFullyComplete=true"); } return parts.join(" "); } function buildControllerManifestReply( manifest: ControllerOrchestrationManifest | undefined, createdTaskIds: string[], state: TeamState | null, fallbackReply: string, ): string { if (!manifest) { const warning = "Warning: controller did not submit a structured orchestration manifest for this run."; return fallbackReply ? `${fallbackReply}\n\n${warning}` : warning; } const actualCreatedTasks = createdTaskIds .map((taskId) => state?.tasks?.[taskId]) .filter((task): task is TaskInfo => !!task); const lines: string[] = [ `Requirement summary: ${manifest.requirementSummary}`, `Required roles: ${manifest.requiredRoles.join(", ") || "none"}`, ]; if (actualCreatedTasks.length > 0) { lines.push("", "Created execution-ready tasks:"); for (const task of actualCreatedTasks) { const roleLabel = task.assignedRole ? ` (${task.assignedRole})` : ""; lines.push(`- [${task.id}] ${task.title}${roleLabel}`); } } else if (manifest.createdTasks.length > 0) { lines.push("", "Manifest planned created tasks:"); for (const task of manifest.createdTasks) { const roleLabel = task.assignedRole ? ` (${task.assignedRole})` : ""; lines.push(`- ${task.title}${roleLabel}: ${task.expectedOutcome}`); } } else { lines.push("", "Created execution-ready tasks: none."); } if (manifest.deferredTasks.length > 0) { lines.push("", "Deferred tasks:"); for (const task of manifest.deferredTasks) { const roleLabel = task.assignedRole ? ` (${task.assignedRole})` : ""; lines.push(`- ${task.title}${roleLabel}: blocked by ${task.blockedBy}; create when ${task.whenReady}`); } } if (manifest.clarificationsNeeded) { lines.push("", "Clarifications needed:"); for (const question of manifest.clarificationQuestions) { lines.push(`- ${question}`); } } // ── Team Kickoff Meeting (brief note; full details rendered in UI) ─── if (manifest.kickoffPlan?.assessments?.length) { const kp = manifest.kickoffPlan; const needed = kp.assessments.filter((a) => a.needed).length; lines.push( "", `Team Kickoff Meeting: ${kp.assessments.length} roles assessed, ${needed} confirmed needed. See the Kickoff Meeting panel in the dashboard for full discussion details.`, ); } if (manifest.handoffPlan) { lines.push("", `Handoff plan: ${manifest.handoffPlan}`); } if (manifest.notes) { lines.push("", `Notes: ${manifest.notes}`); } if (manifest.createdTasks.length !== createdTaskIds.length) { lines.push( "", `Warning: manifest declared ${manifest.createdTasks.length} created task(s), but TeamClaw recorded ${createdTaskIds.length}.`, ); } return lines.join("\n"); } function summarizeManifestExpectedOutcome(task: TaskInfo): string { const raw = task.result || task.progress || task.description || ""; const normalized = raw.replace(/\s+/g, " ").trim(); if (!normalized) { return "Produce the concrete deliverable described by this task and report the result back to the controller."; } if (normalized.length <= 180) { return normalized; } return `${normalized.slice(0, 180).trimEnd()}…`; } function inferManifestRolesFromText(text: string): RoleId[] { const normalized = text.toLowerCase(); const roleIds: RoleId[] = []; for (const role of ROLES) { if (normalized.includes(role.id) || normalized.includes(role.label.toLowerCase())) { roleIds.push(role.id); } } return roleIds; } function inferClarificationQuestionsFromReply(text: string): string[] { const candidates = text .split(/\n+/) .map((line) => line.trim()) .filter((line) => line.includes("?")) .map((line) => line.replace(/^[-*]\s*/, "")) .filter(Boolean); return Array.from(new Set(candidates)).slice(0, 5); } function buildBackfilledControllerManifest( request: string, rawReply: string, createdTaskIds: string[], state: TeamState | null, ): ControllerOrchestrationManifest { const actualCreatedTasks = createdTaskIds .map((taskId) => state?.tasks?.[taskId]) .filter((task): task is TaskInfo => !!task); const inferredRoles = new Set(); for (const task of actualCreatedTasks) { if (task.assignedRole) { inferredRoles.add(task.assignedRole); } } for (const roleId of inferManifestRolesFromText(rawReply)) { inferredRoles.add(roleId); } for (const roleId of inferManifestRolesFromText(request)) { inferredRoles.add(roleId); } // When no roles could be inferred at all, default the first-pass requirement analysis // to architect rather than developer. This keeps repository analysis, feasibility // assessment, and technical decomposition out of implementation-only developer tasks. if (inferredRoles.size === 0) { inferredRoles.add("architect"); } const clarificationQuestions = inferClarificationQuestionsFromReply(rawReply); return { version: "1.0", projectName: actualCreatedTasks.find((task) => task.projectId)?.projectId, requirementSummary: request.replace(/\s+/g, " ").trim() || "Controller requirement summary unavailable.", requiredRoles: Array.from(inferredRoles), clarificationsNeeded: clarificationQuestions.length > 0 && actualCreatedTasks.length === 0, clarificationQuestions, clarificationSchemas: clarificationQuestions.map((question) => ({ kind: "text", title: question, required: true, placeholder: "Provide the missing information", })), createdTasks: actualCreatedTasks.map((task) => ({ title: task.title, assignedRole: task.assignedRole, expectedOutcome: summarizeManifestExpectedOutcome(task), })), deferredTasks: [], handoffPlan: actualCreatedTasks.length > 0 ? "Assigned workers should complete the created execution-ready tasks, report progress, and let the controller schedule downstream work after prerequisites are satisfied." : undefined, notes: "Backfilled by the controller because the model did not submit the required structured manifest.", }; } function looksLikeSoftwareRequirement(request: string): boolean { const normalized = request.toLowerCase(); return /(api|backend|frontend|fastapi|react|vue|node|python|typescript|javascript|sql|database|service|app|web|mobile|docker|kubernetes|deploy|测试|系统|平台|接口|服务|数据库|应用|前端|后端)/.test(normalized); } function isArchitectureFirstRequirement(request: string): boolean { const normalized = request.toLowerCase(); return /(analy[sz]e|analysis|assess|feasibility|architecture|architect|design|plan|decompose|migration|port|rewrite|refactor|audit|review|repo|repository|codebase|golang|go\b|typescript|可行性|评估|架构|设计|分析|拆分|迁移|移植|重写|审计|仓库|代码库)/.test(normalized); } function chooseFallbackAssignedRole(manifest: ControllerOrchestrationManifest, request: string): RoleId { const requiredRoles = manifest.requiredRoles; if (requiredRoles.length === 0) { return isArchitectureFirstRequirement(request) ? "architect" : "developer"; } if (isArchitectureFirstRequirement(request)) { if (requiredRoles.includes("architect")) { return "architect"; } if (requiredRoles.includes("pm")) { return "pm"; } } if (requiredRoles.includes("developer") && requiredRoles.length === 1) { return "developer"; } return requiredRoles[0] ?? (isArchitectureFirstRequirement(request) ? "architect" : "developer"); } function buildFallbackControllerTaskTitle(request: string, assignedRole?: RoleId): string { const firstMeaningfulLine = request .split(/\n+/) .map((line) => line.replace(/^#+\s*/, "").trim()) .find(Boolean); if (firstMeaningfulLine) { const normalized = firstMeaningfulLine.replace(/^需求[::]?\s*/, "").trim(); if (normalized.length > 0) { const capped = normalized.slice(0, 72).trim(); return /[\u4e00-\u9fff]/.test(capped) ? `实现${capped}` : `Implement ${capped}`; } } if (assignedRole === "architect") { return "Analyze the repository and produce an architecture/feasibility plan"; } return assignedRole === "developer" ? "Implement the requested software deliverable" : `Perform the requested ${assignedRole || "software"} work`; } async function createControllerManagedTask( input: { title: string; description: string; priority?: TaskPriority; assignedRole?: RoleId; createdBy: string; controllerSessionKey?: string; projectName?: string; recommendedSkills?: string[]; }, deps: ControllerHttpDeps, ): Promise { const taskId = generateId(); const now = Date.now(); const repoState = await refreshControllerRepoState(deps); const normalizedSessionKey = input.createdBy === "controller" && input.controllerSessionKey ? normalizeControllerIntakeSessionKey(input.controllerSessionKey) : undefined; const inheritedProject = normalizedSessionKey ? resolveProjectIdentityForSession(normalizedSessionKey, deps.getTeamState()) : undefined; const explicitProjectId = deriveStableProjectKey(input.projectName ?? ""); const projectId = inheritedProject?.projectId ?? (explicitProjectId || undefined); const projectDir = inheritedProject?.projectDir ?? projectId ?? deriveProjectSlug(input.title); const normalizedRecommendedSkills = normalizeRecommendedSkills(input.recommendedSkills ?? []); const task: TaskInfo = { id: taskId, title: input.title, description: input.description, status: "pending", priority: input.priority ?? "medium", assignedRole: input.assignedRole, createdBy: input.createdBy, recommendedSkills: normalizedRecommendedSkills.length > 0 ? normalizedRecommendedSkills : undefined, controllerSessionKey: normalizedSessionKey, projectId, projectDir, createdAt: now, updatedAt: now, }; deps.updateTeamState((state) => { const project = syncProjectRegistryEntry(state, { projectId: task.projectId, projectDir: task.projectDir, aliases: [input.projectName, task.projectId], summary: task.title, updatedAt: now, }); task.projectId = project.projectId; task.projectDir = project.projectDir; state.tasks[taskId] = task; }); recordTaskExecutionEvent(taskId, { type: "lifecycle", phase: "created", source: "controller", status: "pending", message: `Task created by ${input.createdBy}.`, role: input.assignedRole, }, deps); if (repoState?.enabled) { recordTaskExecutionEvent(taskId, { type: "lifecycle", phase: "repo_ready", source: "controller", status: "pending", message: repoState.remoteReady && repoState.remoteUrl ? `Git collaboration ready on ${repoState.defaultBranch} with remote ${repoState.remoteUrl}.` : `Git collaboration ready on ${repoState.defaultBranch} using controller-managed bundle sync.`, role: input.assignedRole, }, deps); } if (normalizedRecommendedSkills.length > 0) { recordTaskExecutionEvent(taskId, { type: "lifecycle", phase: "skills_recommended", source: "controller", status: "pending", message: `Recommended skills: ${normalizedRecommendedSkills.join(", ")}`, role: input.assignedRole, }, deps); } await autoAssignPendingTasks(deps); const updatedTask = deps.getTeamState()?.tasks[taskId]; deps.wsServer.broadcastUpdate({ type: "task:created", data: serializeTask(updatedTask) }); return updatedTask; } async function maybeBackfillExecutionReadyTask( controllerRunId: string, sessionKey: string, request: string, manifest: ControllerOrchestrationManifest, deps: ControllerHttpDeps, options?: { source?: ControllerRunSource; }, ): Promise { if (manifest.createdTasks.length > 0 || manifest.clarificationsNeeded || manifest.requirementFullyComplete) { return []; } if (options?.source === "task_follow_up") { return []; } if (!looksLikeSoftwareRequirement(request)) { return []; } const assignedRole = chooseFallbackAssignedRole(manifest, request); const task = await createControllerManagedTask({ title: buildFallbackControllerTaskTitle(request, assignedRole), description: request, assignedRole, createdBy: "controller", controllerSessionKey: sessionKey, recommendedSkills: resolveRecommendedSkillsForRole(assignedRole, []), }, deps); if (!task) { return []; } manifest.createdTasks = [{ title: task.title, assignedRole: task.assignedRole, expectedOutcome: summarizeManifestExpectedOutcome(task), }]; manifest.notes = manifest.notes ? `${manifest.notes} Controller synthesized a fallback execution-ready task because the model returned no created tasks.` : "Controller synthesized a fallback execution-ready task because the model returned no created tasks."; updateControllerRun(controllerRunId, deps, (run) => { run.manifest = manifest; appendControllerRunEvent(run, { type: "warning", phase: "task_backfilled", source: "controller", status: "running", sessionKey, message: `Controller synthesized fallback task ${task.id} (${task.assignedRole || "unassigned"}).`, }); }); return [task.id]; } function ensureControllerManifest( controllerRunId: string, sessionKey: string, request: string, rawReply: string, createdTaskIds: string[], deps: ControllerHttpDeps, ): ControllerOrchestrationManifest { const currentState = deps.getTeamState(); const existingManifest = currentState?.controllerRuns?.[controllerRunId]?.manifest; if (existingManifest) { return existingManifest; } const manifest = buildBackfilledControllerManifest(request, rawReply, createdTaskIds, currentState); updateControllerRun(controllerRunId, deps, (run) => { run.manifest = manifest; appendControllerRunEvent(run, { type: "warning", phase: "manifest_backfilled", source: "controller", status: "running", sessionKey, message: "Controller did not submit a structured manifest; TeamClaw backfilled a minimal manifest from the recorded run state.", }); }); return manifest; } function buildControllerFollowUpMessage(task: TaskInfo, state: TeamState | null): string { const parts = [ `A controller-created TeamClaw task has ${task.status === "failed" ? "failed" : "completed"}.`, `Task ID: ${task.id}`, `Title: ${task.title}`, task.assignedRole ? `Role: ${task.assignedRole}` : "", "", "## Original Task", task.description || "No task description was recorded.", ]; if (task.result) { parts.push("", "## Task Result", task.result); } const resultContractSection = buildResultContractSection(task); if (resultContractSection) { parts.push("", resultContractSection); } if (task.error) { parts.push("", "## Task Error", task.error); } // Include preview URLs so the controller can present them to the human if (task.resultContract?.deliverables) { const liveDeliverables = task.resultContract.deliverables.filter((d) => d.liveUrl); if (liveDeliverables.length > 0) { parts.push("", "## Live Previews"); for (const d of liveDeliverables) { parts.push(`- ${d.summary || d.value}: ${d.liveUrl}`); } } } // Inject prior manifest context (deferred tasks, clarification questions) so the // controller knows the original plan and can advance it if (state) { const sessionKey = task.controllerSessionKey; if (sessionKey) { const priorRuns = Object.values(state.controllerRuns) .filter((run) => normalizeControllerIntakeSessionKey(run.sessionKey) === normalizeControllerIntakeSessionKey(sessionKey) && run.manifest) .sort((a, b) => b.updatedAt - a.updatedAt); // Collect deferred tasks from the latest manifest that had them const latestWithDeferred = priorRuns.find((run) => run.manifest!.deferredTasks && run.manifest!.deferredTasks.length > 0); if (latestWithDeferred?.manifest?.deferredTasks) { parts.push("", "## Prior Orchestration Plan (Deferred Tasks)"); parts.push("These tasks were identified in the original plan but deferred until prerequisites were met:"); for (const dt of latestWithDeferred.manifest.deferredTasks) { const blockedBy = dt.blockedBy ? ` [blocked by: ${dt.blockedBy}]` : ""; parts.push(`- ${dt.title} (${dt.assignedRole || "any"})${blockedBy}`); } } // Collect only still-pending clarification questions for this session. const pendingClarifications = Object.values(state.clarifications) .filter((clarification) => clarification.status === "pending" && normalizeControllerIntakeSessionKey(clarification.controllerSessionKey) === normalizeControllerIntakeSessionKey(sessionKey), ) .sort((a, b) => b.updatedAt - a.updatedAt); const priorQuestions = pendingClarifications .map((clarification) => clarification.question) .filter(Boolean); if (priorQuestions.length > 0) { parts.push("", "## Prior Clarification Questions (from earlier runs)"); for (const q of priorQuestions.slice(0, 5)) { parts.push(`- ${q}`); } } } } parts.push( "", "## Controller Follow-up", "Continue orchestrating this same requirement.", "Review the current TeamClaw state before acting.", "Create only the next execution-ready task(s) whose prerequisites are now satisfied.", "For any large-scale requirement, prefer parallel fan-out over serial mega-phases: if multiple independent developer workstreams are ready, create all of them now instead of a single umbrella developer task.", "Decompose developer work by module or subsystem with clear file ownership and interfaces so multiple developer workers can collaborate safely in parallel.", "Use TeamClaw's available same-role capacity: create multiple developer tasks when the work can proceed concurrently rather than forcing one developer to carry the whole rewrite alone.", "Do not duplicate tasks that already exist, are active, or are already completed.", "If this task produced a web application with a live preview URL, include it in your reply so the human can verify the result.", "If all planned phases are complete and no follow-ups remain, set requirementFullyComplete=true in the manifest and provide a final delivery summary.", "If no additional task should be created yet, reply briefly and stop.", ); return parts.filter(Boolean).join("\n"); } function buildControllerParallelHelpMessage( task: TaskInfo, state: TeamState | null, input: { requestedBy: string; requestedByRole?: RoleId; targetRole?: RoleId; reason: string; requestedWorkerCount?: number; suggestedWorkstreams: string[]; }, ): string { const targetRole = input.targetRole || input.requestedByRole || task.assignedRole || "developer"; return [ buildControllerFollowUpMessage(task, state), "", "## Parallel Help Request", `Current worker ${input.requestedBy} has asked TeamClaw to expand parallel help for role ${targetRole}.`, input.requestedByRole ? `Requesting worker role: ${input.requestedByRole}` : "", typeof input.requestedWorkerCount === "number" ? `Desired same-role worker capacity for this requirement: ${input.requestedWorkerCount}` : "", `Why more parallel help is needed: ${input.reason}`, input.suggestedWorkstreams.length > 0 ? `Suggested parallel workstreams:\n${input.suggestedWorkstreams.map((item) => `- ${item}`).join("\n")}` : "", "If the suggested workstreams are genuinely independent, create multiple execution-ready tasks for that role now instead of keeping one giant serial task.", "Reuse active tasks when possible, but if no matching tasks already exist, fan out the work into distinct module- or subsystem-scoped tasks that can run concurrently.", ].filter(Boolean).join("\n"); } function buildControllerClarificationAnswerMessage( clarification: ClarificationRequest, answer: string, answeredBy: string, ): string { return [ "The human has answered a pending controller clarification.", `Question: ${clarification.question}`, `Answer: ${answer}`, `Answered by: ${answeredBy}`, "Use this answer to continue the same requirement and update the plan/tasks accordingly.", ].join("\n"); } function buildStructuredClarificationAnswer( clarification: ClarificationRequest, payload: { answer?: string; answerValue?: string; answerValues?: string[]; answerNumber?: number; answerComment?: string; }, ): string { if (typeof payload.answer === "string" && payload.answer.trim()) { return payload.answer.trim(); } const schema = clarification.questionSchema; const optionLabel = (value: string): string => { const match = schema?.options?.find((entry) => entry.value === value); return match?.label || value; }; const parts: string[] = []; if (schema?.kind === "single-select" && payload.answerValue) { parts.push(optionLabel(payload.answerValue)); } else if (schema?.kind === "multi-select" && Array.isArray(payload.answerValues) && payload.answerValues.length > 0) { parts.push(payload.answerValues.map((entry) => optionLabel(entry)).join(", ")); } else if (schema?.kind === "number" && typeof payload.answerNumber === "number" && Number.isFinite(payload.answerNumber)) { parts.push(`${payload.answerNumber}${schema.unit ? ` ${schema.unit}` : ""}`); } else if (payload.answerValue) { parts.push(payload.answerValue); } if (payload.answerComment && payload.answerComment.trim()) { parts.push(parts.length > 0 ? `Additional context: ${payload.answerComment.trim()}` : payload.answerComment.trim()); } return parts.join("\n").trim(); } function normalizeComparableText(value: string): string { return value .toLowerCase() .replace(/\s+/g, " ") .trim(); } function buildManifestClarificationEntries( manifest: ControllerOrchestrationManifest, ): Array<{ question: string; questionSchema?: ClarificationRequest["questionSchema"] }> { const opportunities = Array.isArray(manifest.completionOpportunities) ? manifest.completionOpportunities.filter((entry) => entry.title && entry.value && entry.summary) : []; const completionEntries = manifest.requirementFullyComplete && opportunities.length > 0 ? [{ question: "Would you like TeamClaw to continue with one of these adjacent next steps?", questionSchema: { kind: "single-select" as const, title: "What should TeamClaw do next?", description: opportunities.map((entry) => `- ${entry.title}: ${entry.summary}`).join("\n"), required: false, options: opportunities.map((entry) => ({ value: entry.value, label: entry.title, hint: entry.summary, })), allowOther: true, }, }] : []; const normalizedQuestions = manifest.clarificationQuestions .map((entry) => String(entry || "").trim()) .filter(Boolean); const schemas = Array.isArray(manifest.clarificationSchemas) ? manifest.clarificationSchemas : []; if (schemas.length > 0) { return [ ...schemas.map((schema, index) => ({ question: normalizedQuestions[index] || schema.title, questionSchema: schema, })), ...completionEntries, ]; } return [ ...normalizedQuestions.map((question) => ({ question })), ...completionEntries, ]; } function syncControllerRunClarifications( controllerRunId: string, sessionKey: string, manifest: ControllerOrchestrationManifest, deps: ControllerHttpDeps, ): ClarificationRequest[] { const entries = buildManifestClarificationEntries(manifest); const created: ClarificationRequest[] = []; const superseded: ClarificationRequest[] = []; const now = Date.now(); deps.updateTeamState((state) => { const desiredQuestionKeys = new Set( entries.map((entry) => normalizeComparableText(entry.question)), ); const existingQuestions = new Map( Object.values(state.clarifications) .filter((item) => item.controllerRunId === controllerRunId) .map((item) => [normalizeComparableText(item.question), item]), ); for (const [key, clarification] of existingQuestions.entries()) { if ( clarification.status === "pending" && !desiredQuestionKeys.has(key) ) { clarification.status = "answered"; clarification.answer = "Automatically superseded by the latest controller clarification set."; clarification.answeredBy = "system"; clarification.answeredAt = now; clarification.updatedAt = now; superseded.push({ ...clarification }); } } for (const entry of entries) { const key = normalizeComparableText(entry.question); if (existingQuestions.has(key)) { continue; } const clarification: ClarificationRequest = { id: generateId(), taskId: "", controllerRunId, controllerSessionKey: sessionKey, requestedBy: "controller", question: entry.question, questionSchema: entry.questionSchema, blockingReason: "The controller needs this information before it can confidently continue planning and downstream task creation.", context: manifest.requirementSummary, status: "pending", createdAt: now, updatedAt: now, }; state.clarifications[clarification.id] = clarification; created.push(clarification); } }); for (const clarification of created) { deps.wsServer.broadcastUpdate({ type: "clarification:requested", data: clarification }); } for (const clarification of superseded) { deps.wsServer.broadcastUpdate({ type: "clarification:answered", data: clarification }); } return created; } function reconcileControllerClarifications(deps: ControllerHttpDeps): TeamState | null { const state = deps.getTeamState(); if (!state) { return null; } const superseded: ClarificationRequest[] = []; deps.updateTeamState((draft) => { const runsBySession = new Map(); for (const run of Object.values(draft.controllerRuns)) { const normalizedSessionKey = normalizeControllerIntakeSessionKey(run.sessionKey); const bucket = runsBySession.get(normalizedSessionKey) ?? []; bucket.push(run); runsBySession.set(normalizedSessionKey, bucket); } for (const clar of Object.values(draft.clarifications)) { if (clar.status !== "pending" || clar.requestedBy !== "controller" || clar.taskId) { continue; } const normalizedSessionKey = normalizeControllerIntakeSessionKey(clar.controllerSessionKey); const sessionRuns = runsBySession.get(normalizedSessionKey) ?? []; const supersedingRun = sessionRuns.find((run) => run.updatedAt > clar.updatedAt && ( run.manifest?.createdTasks.length || run.manifest?.requirementFullyComplete ), ); if (!supersedingRun) { continue; } clar.status = "answered"; clar.answer = "Automatically superseded by later controller progress."; clar.answeredBy = "system"; clar.answeredAt = supersedingRun.updatedAt; clar.updatedAt = supersedingRun.updatedAt; superseded.push({ ...clar }); } }); for (const clarification of superseded) { deps.wsServer.broadcastUpdate({ type: "clarification:answered", data: clarification }); } for (const run of Object.values(state.controllerRuns)) { if (!run.manifest?.clarificationsNeeded) { continue; } syncControllerRunClarifications(run.id, run.sessionKey, run.manifest, deps); } return deps.getTeamState(); } function buildControllerRateLimitProbeMessage( sourceTaskId?: string, sourceTaskTitle?: string, ): string { const workflowLabel = sourceTaskTitle ? `${sourceTaskTitle}${sourceTaskId ? ` (${sourceTaskId})` : ""}` : (sourceTaskId ? `task ${sourceTaskId}` : "this controller workflow"); return [ `This is a follow-up check for ${workflowLabel}.`, "The earlier controller run appears to be delayed by upstream model rate limiting.", "Do not restart the workflow from scratch.", "Do not duplicate tasks that already exist, are active, or are completed.", "If the earlier controller follow-up is fully complete now, immediately submit the required structured manifest for that same workflow step and provide the final orchestration reply.", `If the earlier controller follow-up is not complete yet, reply with exactly ${CONTROLLER_RATE_LIMIT_WAITING_SENTINEL}.`, ].join("\n"); } function buildControllerInactivityProbeMessage( inactivityMs: number, sourceTaskId?: string, sourceTaskTitle?: string, ): string { const workflowLabel = sourceTaskTitle ? `${sourceTaskTitle}${sourceTaskId ? ` (${sourceTaskId})` : ""}` : (sourceTaskId ? `task ${sourceTaskId}` : "this controller workflow"); return [ `This is a follow-up check for ${workflowLabel}.`, `There has been no new visible controller workflow progress for over ${formatDuration(inactivityMs)}.`, "Do not restart the workflow from scratch.", "Do not duplicate tasks that already exist, are active, or are completed.", "If the earlier controller follow-up is fully complete now, immediately submit the required structured manifest for that same workflow step and provide the final orchestration reply.", `If the earlier controller follow-up is not complete yet, reply with exactly ${CONTROLLER_RATE_LIMIT_WAITING_SENTINEL}.`, ].join("\n"); } async function checkAndGenerateReport(task: TaskInfo, deps: ControllerHttpDeps): Promise { const sessionKey = task.controllerSessionKey; if (!sessionKey) return; const state = deps.getTeamState(); if (!state) return; if (!isSessionComplete(sessionKey, state, normalizeControllerIntakeSessionKey)) return; const report = generateDeliveryReport(sessionKey, state, normalizeControllerIntakeSessionKey); if (!report) return; // Check if we already generated a report for this session const existingReport = state.reports?.[report.id]; if (existingReport) return; // Store a lightweight record in state (full report is generated on demand from live state) deps.updateTeamState((s) => { if (!s.reports) s.reports = {}; s.reports[report.id] = { id: report.id, sessionKey: report.sessionKey, generatedAt: report.generatedAt, projectName: report.projectName, requirementSummary: report.requirementSummary, status: report.status, taskCount: report.taskCount, deliverableCount: report.deliverables.length, previewCount: report.deliverables.filter((d) => d.previewUrl).length, }; }); const reportUrl = `/api/v1/reports/${encodeURIComponent(sessionKey)}`; deps.wsServer.broadcastUpdate({ type: "report:ready", data: { sessionKey, reportUrl, projectName: report.projectName, status: report.status }, }); deps.logger.info(`Controller: delivery report generated for session ${sessionKey}: ${reportUrl}`); } async function continueControllerWorkflow(task: TaskInfo, deps: ControllerHttpDeps): Promise { if (task.createdBy !== "controller") { return; } const sessionKey = resolveControllerWorkflowSessionKey(task, deps.getTeamState()); if (!sessionKey) { return; } if (task.controllerSessionKey !== sessionKey) { deps.updateTeamState((state) => { const currentTask = state.tasks[task.id]; if (currentTask) { currentTask.controllerSessionKey = sessionKey; } }); } await runControllerIntake(buildControllerFollowUpMessage(task, deps.getTeamState()), sessionKey, deps, { source: "task_follow_up", sourceTaskId: task.id, sourceTaskTitle: task.title, }); } async function runControllerIntake( message: string, sessionKey: string, deps: ControllerHttpDeps, options?: { source?: ControllerRunSource; sourceTaskId?: string; sourceTaskTitle?: string; }, ): Promise<{ sessionKey: string; runId: string; reply: string; controllerRunId: string }> { const normalizedSessionKey = normalizeControllerIntakeSessionKey(sessionKey); return withSerializedControllerIntake(normalizedSessionKey, async () => { let lastError: Error | null = null; for (let attempt = 0; attempt <= CONTROLLER_INTAKE_MAX_RETRIES; attempt++) { try { return await runControllerIntakeUnlocked(message, normalizedSessionKey, deps, options); } catch (err) { lastError = err instanceof Error ? err : new Error(String(err)); const errorText = lastError.message; // Only retry on transient server errors (500/502/503), not on client errors, // timeouts, or other failures that retrying won't fix. if ( attempt === CONTROLLER_INTAKE_MAX_RETRIES || !CONTROLLER_INTAKE_RETRYABLE_ERROR_PATTERN.test(errorText) || errorText.includes("timed out") ) { throw lastError; } // Record a visible retry event so the human can see what happened. deps.logger.warn( `Controller: intake attempt ${attempt + 1} failed with transient error: ${errorText.slice(0, 200)}. Retrying in ${CONTROLLER_INTAKE_RETRY_DELAY_MS * (attempt + 1) / 1000}s...`, ); await new Promise((resolve) => setTimeout(resolve, CONTROLLER_INTAKE_RETRY_DELAY_MS * (attempt + 1))); } } throw lastError!; }); } async function runControllerIntakeUnlocked( message: string, sessionKey: string, deps: ControllerHttpDeps, options?: { source?: ControllerRunSource; sourceTaskId?: string; sourceTaskTitle?: string; }, ): Promise<{ sessionKey: string; runId: string; reply: string; controllerRunId: string }> { const taskIdsBeforeRun = collectTaskIds(deps.getTeamState()); const controllerRun = createControllerRun(message, sessionKey, deps, options); recordControllerRunEvent(controllerRun.id, { type: "lifecycle", phase: "queued", source: "controller", status: "pending", sessionKey, message: "Controller intake queued.", }, deps); const runResult = await deps.runtime.subagent.run({ sessionKey, message, extraSystemPrompt: buildControllerIntakeSystemPrompt(deps), idempotencyKey: `controller-intake-${generateId()}`, }); recordControllerRunEvent(controllerRun.id, { type: "lifecycle", phase: "run_started", source: "controller", status: "running", sessionKey, runId: runResult.runId, message: `Controller intake started (${runResult.runId}).`, }, deps); const rateLimitState: { active: boolean; visibleAt?: number; nextProbeAt?: number; probeCount: number; } = { active: false, probeCount: 0, }; const inactivityState: { active: boolean; visibleAt?: number; nextProbeAt?: number; probeCount: number; } = { active: false, nextProbeAt: Date.now() + deps.config.taskTimeoutMs, probeCount: 0, }; const markRateLimitWaiting = async (): Promise => { if (rateLimitState.active) { return; } const now = Date.now(); rateLimitState.active = true; rateLimitState.visibleAt = now; rateLimitState.nextProbeAt = now + CONTROLLER_RATE_LIMIT_STALL_PROBE_MS; recordControllerRunEvent(controllerRun.id, { type: "progress", phase: "model_rate_limit_waiting", source: "controller", status: "running", sessionKey, runId: runResult.runId, message: "Model rate limit reached. OpenClaw is retrying upstream; TeamClaw will keep waiting for the controller workflow to continue.", }, deps); }; const clearRateLimitWaiting = (): void => { rateLimitState.active = false; rateLimitState.visibleAt = undefined; rateLimitState.nextProbeAt = undefined; }; const noteObservedControllerActivity = (): void => { inactivityState.active = false; inactivityState.visibleAt = undefined; inactivityState.nextProbeAt = Date.now() + deps.config.taskTimeoutMs; }; const extractSessionAssistantReply = async (): Promise => { const sessionMessages = await deps.runtime.subagent.getSessionMessages({ sessionKey, limit: 100, }); return extractLastAssistantText(sessionMessages.messages); }; const probeRateLimitedControllerCompletion = async (): Promise => { rateLimitState.probeCount += 1; const now = Date.now(); rateLimitState.visibleAt = now; rateLimitState.nextProbeAt = now + CONTROLLER_RATE_LIMIT_STALL_PROBE_MS; recordControllerRunEvent(controllerRun.id, { type: "progress", phase: "model_rate_limit_probe", source: "controller", status: "running", sessionKey, runId: runResult.runId, message: `Model rate limit has delayed controller orchestration for over ${formatDuration(CONTROLLER_RATE_LIMIT_STALL_PROBE_MS)}. Re-checking whether this workflow step has already completed.`, }, deps); const probeRun = await deps.runtime.subagent.run({ sessionKey, message: buildControllerRateLimitProbeMessage(options?.sourceTaskId, options?.sourceTaskTitle), extraSystemPrompt: buildControllerIntakeSystemPrompt(deps), idempotencyKey: `${runResult.runId}:rate-limit-probe:${rateLimitState.probeCount}`, }); const probeWait = await deps.runtime.subagent.waitForRun({ runId: probeRun.runId, timeoutMs: CONTROLLER_RATE_LIMIT_PROBE_TIMEOUT_MS, }); if (probeWait.status !== "ok") { return null; } const probeReply = await extractSessionAssistantReply(); if (!probeReply || isRateLimitMessage(probeReply) || isStillWaitingResponse(probeReply)) { recordControllerRunEvent(controllerRun.id, { type: "progress", phase: "model_rate_limit_still_waiting", source: "controller", status: "running", sessionKey, runId: runResult.runId, message: "The controller workflow is still waiting on model availability. TeamClaw will continue waiting.", }, deps); return null; } clearRateLimitWaiting(); noteObservedControllerActivity(); return probeReply; }; const probeInactiveControllerCompletion = async (): Promise => { inactivityState.probeCount += 1; const now = Date.now(); inactivityState.active = true; inactivityState.visibleAt = now; inactivityState.nextProbeAt = now + deps.config.taskTimeoutMs; recordControllerRunEvent(controllerRun.id, { type: "progress", phase: "inactivity_probe", source: "controller", status: "running", sessionKey, runId: runResult.runId, message: `No new controller workflow output has appeared for over ${formatDuration(deps.config.taskTimeoutMs)}. Re-checking whether this orchestration step is complete or still running.`, }, deps); const probeRun = await deps.runtime.subagent.run({ sessionKey, message: buildControllerInactivityProbeMessage( deps.config.taskTimeoutMs, options?.sourceTaskId, options?.sourceTaskTitle, ), extraSystemPrompt: buildControllerIntakeSystemPrompt(deps), idempotencyKey: `${runResult.runId}:inactivity-probe:${inactivityState.probeCount}`, }); const probeWait = await deps.runtime.subagent.waitForRun({ runId: probeRun.runId, timeoutMs: CONTROLLER_INACTIVITY_PROBE_TIMEOUT_MS, }); if (probeWait.status === "error" && isRateLimitMessage(probeWait.error || "")) { await markRateLimitWaiting(); return null; } if (probeWait.status !== "ok") { return null; } const probeReply = await extractSessionAssistantReply(); if (!probeReply || isRateLimitMessage(probeReply) || isStillWaitingResponse(probeReply)) { inactivityState.active = false; inactivityState.visibleAt = undefined; inactivityState.nextProbeAt = Date.now() + deps.config.taskTimeoutMs; recordControllerRunEvent(controllerRun.id, { type: "progress", phase: "inactivity_still_waiting", source: "controller", status: "running", sessionKey, runId: runResult.runId, message: "The controller workflow is still running without a final result yet. TeamClaw will keep waiting instead of failing the run.", }, deps); return null; } if (rateLimitState.active) { clearRateLimitWaiting(); } noteObservedControllerActivity(); return probeReply; }; let waitResult: Awaited> = { status: "timeout" }; let completionOverride: string | null = null; while (true) { if (rateLimitState.active && (rateLimitState.nextProbeAt ?? Number.POSITIVE_INFINITY) <= Date.now()) { completionOverride = await probeRateLimitedControllerCompletion(); if (completionOverride) { waitResult = { status: "ok" }; break; } } if (!rateLimitState.active && (inactivityState.nextProbeAt ?? Number.POSITIVE_INFINITY) <= Date.now()) { completionOverride = await probeInactiveControllerCompletion(); if (completionOverride) { waitResult = { status: "ok" }; break; } } waitResult = await deps.runtime.subagent.waitForRun({ runId: runResult.runId, timeoutMs: CONTROLLER_RUN_WAIT_SLICE_MS, }); if (waitResult.status === "ok") { clearRateLimitWaiting(); noteObservedControllerActivity(); break; } if (waitResult.status === "error") { if (isRateLimitMessage(waitResult.error || "")) { await markRateLimitWaiting(); continue; } break; } } if (waitResult.status !== "ok") { const errorMessage = waitResult.error || "Controller intake failed"; const createdTaskIds = tagControllerCreatedTasks(taskIdsBeforeRun, sessionKey, deps); updateControllerRun(controllerRun.id, deps, (run) => { run.createdTaskIds = createdTaskIds; run.error = errorMessage; appendControllerRunEvent(run, { type: "error", phase: "run_failed", source: "controller", status: "failed", sessionKey, runId: runResult.runId, message: errorMessage, }); }); throw new Error(errorMessage); } let createdTaskIds = tagControllerCreatedTasks(taskIdsBeforeRun, sessionKey, deps); const rawReply = completionOverride || await extractSessionAssistantReply() || "Controller completed the intake run but did not return any text."; const recordedManifest = ensureControllerManifest( controllerRun.id, sessionKey, message, rawReply, createdTaskIds, deps, ); const fallbackTaskIds = await maybeBackfillExecutionReadyTask( controllerRun.id, sessionKey, message, recordedManifest, deps, { source: options?.source }, ); if (fallbackTaskIds.length > 0) { createdTaskIds = Array.from(new Set([...createdTaskIds, ...fallbackTaskIds])); } syncControllerRunClarifications(controllerRun.id, sessionKey, recordedManifest, deps); const reconciledTasks = reconcileControllerManifestTaskBindings(sessionKey, createdTaskIds, recordedManifest, deps); // ── Automatic Team Kickoff ──────────────────────────────────────────── // For complex projects (3+ roles), automatically run a kickoff meeting // so every candidate role can assess the requirement collaboratively. // This happens AFTER the initial LLM pass so we know which roles are // needed, but we store the assessments for visibility and future // refinement passes. const AUTO_KICKOFF_ROLE_THRESHOLD = 3; const isFollowUp = options?.source === "task_follow_up"; const kickoffHandler = deps.getKickoffHandler?.(); if ( !isFollowUp && kickoffHandler && recordedManifest.requiredRoles.length >= AUTO_KICKOFF_ROLE_THRESHOLD && !recordedManifest.clarificationsNeeded ) { deps.logger.info( `Controller: auto-kickoff triggered for ${recordedManifest.requiredRoles.length} roles: ${recordedManifest.requiredRoles.join(", ")}`, ); recordControllerRunEvent(controllerRun.id, { type: "lifecycle", phase: "kickoff_started", source: "controller", status: "running", sessionKey, message: `Auto-kickoff: provisioning ${recordedManifest.requiredRoles.length} candidate roles for team assessment.`, }, deps); try { const kickoffResult = await kickoffHandler( recordedManifest.requiredRoles as RoleId[], recordedManifest.requiredRoles.length >= 5 ? "complex" : "medium", message, ); // Store kickoff assessments on the manifest for UI/API visibility recordedManifest.kickoffPlan = { assessments: kickoffResult.assessments, summary: kickoffResult.summary, triggeredAt: Date.now(), }; updateControllerRun(controllerRun.id, deps, (run) => { if (run.manifest) { run.manifest.kickoffPlan = recordedManifest.kickoffPlan; } appendControllerRunEvent(run, { type: "lifecycle", phase: "kickoff_completed", source: "controller", status: "running", sessionKey, message: `Team kickoff completed: ${kickoffResult.assessments.length} assessments collected. ${kickoffResult.summary.slice(0, 200)}`, }); }); deps.logger.info( `Controller: auto-kickoff completed with ${kickoffResult.assessments.length} assessments`, ); } catch (err) { const errMsg = err instanceof Error ? err.message : String(err); deps.logger.warn(`Controller: auto-kickoff failed: ${errMsg}`); recordControllerRunEvent(controllerRun.id, { type: "lifecycle", phase: "kickoff_failed", source: "controller", status: "running", sessionKey, message: `Auto-kickoff failed: ${errMsg}. Proceeding with controller-only planning.`, }, deps); } } const latestTeamState = deps.getTeamState(); const reply = buildControllerManifestReply(recordedManifest, reconciledTasks.taskIds, latestTeamState, rawReply); updateControllerRun(controllerRun.id, deps, (run) => { run.reply = reply; run.error = undefined; run.createdTaskIds = reconciledTasks.taskIds; appendControllerRunEvent(run, { type: "output", phase: "final_reply", source: "subagent", status: "running", sessionKey, runId: runResult.runId, message: reply, }); if (reconciledTasks.linkedTaskIds.length > 0) { appendControllerRunEvent(run, { type: "lifecycle", phase: "tasks_linked", source: "controller", status: "running", sessionKey, runId: runResult.runId, message: `Controller linked ${reconciledTasks.linkedTaskIds.length} existing task(s) into this workflow: ${reconciledTasks.linkedTaskIds.join(", ")}`, }); } if (reconciledTasks.taskIds.length > 0) { appendControllerRunEvent(run, { type: "lifecycle", phase: "tasks_created", source: "controller", status: "running", sessionKey, runId: runResult.runId, message: `Controller activated ${reconciledTasks.taskIds.length} execution-ready task(s): ${reconciledTasks.taskIds.join(", ")}`, }); } appendControllerRunEvent(run, { type: "lifecycle", phase: "run_completed", source: "controller", status: "completed", sessionKey, runId: runResult.runId, message: "Controller intake completed.", }); }); return { sessionKey, runId: runResult.runId, reply, controllerRunId: controllerRun.id, }; } function summarizeTaskForAssignment(task: TaskInfo): string { const lastExecutionMessage = task.execution?.events[task.execution.events.length - 1]?.message; const contractSummary = task.resultContract ? [ task.resultContract.summary, ...task.resultContract.deliverables.slice(0, 3).map((deliverable) => `${deliverable.kind}: ${deliverable.value}`), ].join(" | ") : ""; const raw = contractSummary || task.result || task.progress || lastExecutionMessage || task.description || ""; const normalized = raw.replace(/\s+/g, " ").trim(); if (!normalized) { return "No upstream summary available."; } if (normalized.length <= MAX_TASK_CONTEXT_SUMMARY_CHARS) { return normalized; } return `${normalized.slice(0, MAX_TASK_CONTEXT_SUMMARY_CHARS).trimEnd()}…`; } function buildRecentCompletedTaskContext(task: TaskInfo, state: TeamState | null): string { if (!state) { return ""; } // Only include tasks from the same session/project to avoid cross-project contamination. // Tasks sharing the same controllerSessionKey belong to the same user requirement. const sameSession = task.controllerSessionKey ? (candidate: TaskInfo) => candidate.controllerSessionKey === task.controllerSessionKey : () => true; // If no session key, fall back to all completed tasks (legacy behavior) const recentCompletedTasks = Object.values(state.tasks) .filter((candidate) => candidate.id !== task.id && candidate.status === "completed") .filter(sameSession) .filter((candidate) => (candidate.completedAt ?? candidate.updatedAt) <= task.createdAt) .sort((a, b) => (b.completedAt ?? b.updatedAt) - (a.completedAt ?? a.updatedAt)) .slice(0, MAX_RECENT_TASK_CONTEXT) .reverse(); if (recentCompletedTasks.length === 0) { return ""; } return [ "## Recent Completed Team Deliverables", "Use these upstream outputs before requesting clarification.", "If a summary references a filename or task ID, search the shared workspace for it first.", "Do not try to inspect another worker's OpenClaw session or session key directly; those sessions are isolated per worker.", ...recentCompletedTasks.map((candidate) => { const roleLabel = candidate.assignedRole ? (ROLES.find((role) => role.id === candidate.assignedRole)?.label ?? candidate.assignedRole) : "Unassigned"; return `- [${candidate.id}] ${candidate.title} (${roleLabel}): ${summarizeTaskForAssignment(candidate)}`; }), ].join("\n"); } function buildRecommendedSkillsContext(task: TaskInfo): string { const recommendedSkills = resolveRecommendedSkillsForRole(task.assignedRole, task.recommendedSkills ?? []); if (recommendedSkills.length === 0) { return ""; } return [ "## Recommended Skills", "- Prefer these skill slugs for this task when relevant:", ...recommendedSkills.map((skill) => ` - ${skill}`), "- Before starting, search/install missing recommended skills in the current workspace when the runtime supports it.", "- Prefer exact ClawHub/OpenClaw skill slugs over vague descriptions whenever possible.", ].join("\n"); } function buildTaskContextSnapshot(task: TaskInfo, state: TeamState | null): TaskAssignmentPayload["teamContext"] | undefined { if (!state || !task.controllerSessionKey) { return undefined; } const normalizedSessionKey = normalizeControllerIntakeSessionKey(task.controllerSessionKey); const summarize = (candidate: TaskInfo) => ({ id: candidate.id, title: candidate.title, assignedRole: candidate.assignedRole, status: candidate.status, summary: summarizeTaskForAssignment(candidate), }); const sameSessionTasks = Object.values(state.tasks) .filter((candidate) => candidate.id !== task.id) .filter((candidate) => normalizeControllerIntakeSessionKey(candidate.controllerSessionKey) === normalizedSessionKey); const latestRun = Object.values(state.controllerRuns) .filter((run) => normalizeControllerIntakeSessionKey(run.sessionKey) === normalizedSessionKey) .sort((a, b) => b.updatedAt - a.updatedAt)[0]; return { requirementSummary: latestRun?.manifest?.requirementSummary || latestRun?.request, projectName: latestRun?.manifest?.projectName, requiredRoles: latestRun?.manifest?.requiredRoles, activeTasks: sameSessionTasks.filter((candidate) => candidate.status === "assigned" || candidate.status === "in_progress").slice(0, 8).map(summarize), blockedTasks: sameSessionTasks.filter((candidate) => candidate.status === "blocked").slice(0, 6).map(summarize), recentCompletedTasks: sameSessionTasks .filter((candidate) => candidate.status === "completed") .sort((a, b) => (b.completedAt ?? b.updatedAt) - (a.completedAt ?? a.updatedAt)) .slice(0, 8) .map(summarize), pendingClarifications: Object.values(state.clarifications) .filter((clarification) => clarification.status === "pending") .filter((clarification) => normalizeControllerIntakeSessionKey(clarification.controllerSessionKey) === normalizedSessionKey) .slice(0, 5) .map((clarification) => ({ question: clarification.question, blockingReason: clarification.blockingReason, })), handoffPlan: latestRun?.manifest?.handoffPlan, notes: latestRun?.manifest?.notes, kickoffSummary: latestRun?.manifest?.kickoffPlan?.summary, }; } function buildTaskAssignmentDescription(task: TaskInfo, state: TeamState | null, repoInfo?: RepoSyncInfo): string { const parts = [task.description]; const projectContext = buildProjectDirectoryContext(task.projectDir); if (projectContext) { parts.push("", projectContext); } const recommendedSkillsContext = buildRecommendedSkillsContext(task); if (recommendedSkillsContext) { parts.push("", recommendedSkillsContext); } const patternsContext = buildConsolidatedPatternsContext(); if (patternsContext) { parts.push("", patternsContext); } const recentContext = buildRecentCompletedTaskContext(task, state); if (recentContext) { parts.push("", recentContext); } if (repoInfo?.enabled) { parts.push("", buildRepoTaskContext(repoInfo)); } return parts.join("\n"); } function buildProjectDirectoryContext(projectDir?: string): string | null { if (!projectDir) { return null; } const workspaceRelativePath = buildTeamClawProjectWorkspacePath(projectDir); const absoluteProjectPath = path.join(resolveTeamClawProjectsDir(), projectDir).replace(/\\/gu, "/"); return [ "## Authoritative Project Paths", `- Workspace-relative project path: \`${workspaceRelativePath}/\``, `- Absolute project path: \`${absoluteProjectPath}/\``, "- If the task description mentions any other workspace path, treat it as stale text from an older layout.", "- Resolve project-local files (for example `ARCHITECTURE.md`, `README.md`, `package.json`, `data/...`) inside the authoritative project path above.", ].join("\n"); } function buildRepoTaskContext(repoInfo: RepoSyncInfo): string { const lines = [ "## TeamClaw Git Collaboration", "- TeamClaw manages a git-backed project workspace for this task.", `- Sync mode: ${repoInfo.mode}.`, `- Default branch: ${repoInfo.defaultBranch}.`, ]; if (repoInfo.headCommit) { const headSummary = repoInfo.headSummary ? ` "${repoInfo.headSummary}"` : ""; lines.push(`- Current HEAD: ${repoInfo.headCommit}${headSummary}.`); } lines.push("- TeamClaw syncs the workspace checkout before task execution when needed."); lines.push("- Treat the current workspace as the canonical repo checkout; do not delete `.git` or replace the repo with ad-hoc archives."); return lines.join("\n"); } async function refreshControllerRepoState(deps: ControllerHttpDeps): Promise { if (!deps.config.gitEnabled) { return null; } try { const repo = await ensureControllerGitRepo(deps.config, deps.logger); if (repo) { deps.updateTeamState((s) => { s.repo = repo; }); } return repo; } catch (err) { const message = err instanceof Error ? err.message : String(err); deps.logger.warn(`Controller: failed to refresh git repo state: ${message}`); deps.updateTeamState((s) => { if (s.repo?.enabled) { s.repo = { ...s.repo, error: message, lastPreparedAt: Date.now(), }; } }); return deps.getTeamState()?.repo ?? null; } } function scheduleProvisioningReconcile(deps: ControllerHttpDeps, reason: string): void { void deps.workerProvisioningManager?.requestReconcile(reason); } function broadcastTaskExecutionEvent( taskId: string, task: TaskInfo, event: TaskExecutionEvent, deps: ControllerHttpDeps, ): void { deps.wsServer.broadcastUpdate({ type: "task:execution", data: { taskId, event, execution: buildTaskExecutionSummary(task.execution), }, }); } function recordTaskExecutionEvent( taskId: string, input: TaskExecutionEventInput, deps: ControllerHttpDeps, ): { task?: TaskInfo; event?: TaskExecutionEvent; statusChanged: boolean } { const { updateTeamState, wsServer } = deps; let statusChanged = false; let event: TaskExecutionEvent | undefined; const state = updateTeamState((s) => { const task = s.tasks[taskId]; if (!task) { return; } const previousStatus = task.status; event = appendTaskExecutionEvent(task, input); statusChanged = previousStatus !== task.status; }); const updatedTask = state.tasks[taskId]; if (updatedTask && event) { broadcastTaskExecutionEvent(taskId, updatedTask, event, deps); if (statusChanged) { wsServer.broadcastUpdate({ type: "task:updated", data: serializeTask(updatedTask) }); } } return { task: updatedTask, event, statusChanged }; } function canAcceptWorkerUpdate(task: TaskInfo | undefined, workerId: string): boolean { if (!task || task.assignedWorkerId !== workerId || task.completedAt) { return false; } return task.status === "assigned" || task.status === "in_progress" || task.status === "review" || task.status === "completed" || task.status === "failed"; } async function cancelTaskExecution( taskId: string, workerId: string | undefined, reason: string, deps: ControllerHttpDeps, ): Promise { if (!workerId) { return; } const worker = deps.getTeamState()?.workers[workerId]; if (!worker) { return; } let cancelled = false; try { const res = await fetch(`${worker.url}/api/v1/tasks/${taskId}/cancel`, { method: "POST", }); cancelled = res.ok; if (!res.ok) { deps.logger.warn(`Controller: worker cancel failed for ${taskId} on ${workerId} (${res.status})`); } } catch (err) { deps.logger.warn(`Controller: failed to cancel task ${taskId} on ${workerId}: ${String(err)}`); } if (!cancelled) { return; } recordTaskExecutionEvent(taskId, { type: "lifecycle", phase: "execution_cancelled", source: "controller", message: `Cancelled active execution before ${reason}.`, workerId, }, deps); } function serveStaticFile(res: ServerResponse, filePath: string, contentType: string): void { try { const content = fs.readFileSync(filePath); res.writeHead(200, { "Content-Type": contentType, "Access-Control-Allow-Origin": "*", }); res.end(content); } catch { sendError(res, 404, "File not found"); } } function workspaceRequestErrorStatus(err: unknown): number { if (err && typeof err === "object" && "code" in err && (err as { code?: unknown }).code === "ENOENT") { return 404; } return 400; } function workspaceRequestErrorMessage(err: unknown): string { return err instanceof Error ? err.message : "Workspace request failed"; } function getEffectiveStartupReadiness( deps: ControllerHttpDeps, state: TeamState | null, ): StartupProvisioningReadiness | null { if (!deps.workerProvisioningManager?.isEnabled()) { return null; } const requiredRoles = deps.config.workerProvisioningRoles.length > 0 ? [...new Set(deps.config.workerProvisioningRoles)] : ["developer"]; const readyWorkerIds = requiredRoles .map((role) => Object.values(state?.workers ?? {}).find((worker) => worker.role === role && (worker.status === "idle" || worker.status === "busy") )?.id) .filter((workerId): workerId is string => Boolean(workerId)); if (readyWorkerIds.length === requiredRoles.length) { const recorded = state?.provisioning?.startupReadiness; return { status: "ready", startedAt: recorded?.startedAt ?? state?.createdAt ?? Date.now(), checkedAt: Date.now(), attempts: recorded?.attempts ?? 0, requiredRoles, readyWorkerIds, message: recorded?.status === "ready" ? recorded.message : `Startup provisioning ready with ${readyWorkerIds.length} warm worker(s).`, }; } return state?.provisioning?.startupReadiness ?? { status: "checking", startedAt: state?.createdAt ?? Date.now(), checkedAt: Date.now(), attempts: 0, requiredRoles, readyWorkerIds, message: `Startup provisioning warm-up is still initializing for ${requiredRoles.join(", ")}.`, }; } function buildStartupReadinessMessage(readiness: StartupProvisioningReadiness): string { const requiredRoles = readiness.requiredRoles.length > 0 ? readiness.requiredRoles.join(", ") : "configured startup roles"; if (readiness.status === "ready") { return readiness.message ?? "Startup provisioning is ready."; } if (readiness.status === "checking") { return readiness.message ?? `Startup provisioning is still warming workers for ${requiredRoles}.`; } return readiness.message ?? `Startup provisioning is degraded for ${requiredRoles}. Check provisioning logs and fix worker startup before retrying.`; } function shouldBlockControllerUntilProvisioningReady( deps: ControllerHttpDeps, state: TeamState | null, ): { blocked: boolean; readiness: StartupProvisioningReadiness | null } { const readiness = getEffectiveStartupReadiness(deps, state); if (!readiness) { return { blocked: false, readiness: null }; } return { blocked: readiness.status !== "ready", readiness, }; } /** * Filter deliverables that don't belong to the current task's project directory. * This prevents cross-project contamination when the model references stale files * from previous sessions visible in the shared workspace. */ function filterStaleDeliverables( contract: WorkerTaskResultContract, taskProjectDir: string | undefined, ): WorkerTaskResultContract { if (!taskProjectDir) return contract; // Normalize: "projects/foo-bar/" → "projects/foo-bar" const normalizedDir = taskProjectDir.replace(/\/$/u, ""); const filtered = contract.deliverables.filter((d) => { const val = (d.value ?? "").replace(/\/$/u, ""); if (!val) return true; // keep notes/commands without paths if (d.kind === "note" || d.kind === "command") return true; // Accept deliverables whose path contains the projectDir or is rooted in teamclaw/projects/{projectDir} if (val.includes(normalizedDir)) return true; // Accept relative paths that look like project-internal (no slashes or relative) if (!val.includes("/") || val.startsWith("./")) return true; // Reject paths from other project directories return false; }); if (filtered.length === contract.deliverables.length) return contract; return { ...contract, deliverables: filtered }; } /** * Strategy 4: When text-based enrichment found no HTML file references, * scan the workspace filesystem for HTML files and create a web-app deliverable. * This handles workers that return abstract summaries without mentioning specific paths. */ function enrichWithFilesystemHtmlScan( contract: WorkerTaskResultContract, taskProjectDir?: string, ): WorkerTaskResultContract | null { const existingWebApp = contract.deliverables.find((d) => d.artifactType === "web-app"); if (existingWebApp) { const cwd = existingWebApp.previewCwd?.trim(); if (cwd && cwd !== "." && cwd !== "./") { return null; } // Fall through — existing web-app has root previewCwd, try to improve it } let workspaceDir: string; let openclawWorkspaceDir: string; try { workspaceDir = resolveTeamClawWorkspaceDir(); openclawWorkspaceDir = resolveTeamClawAgentWorkspaceRootDir(); } catch { return null; } // Recursively scan the workspace for HTML files (up to 3 levels deep) const MAX_DEPTH = 3; const htmlCandidates: { dirPath: string; filename: string }[] = []; function scanDir(dir: string, depth: number) { if (depth > MAX_DEPTH) return; let entries: fs.Dirent[]; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; } for (const entry of entries) { if (entry.name.startsWith(".") || entry.name === "node_modules" || entry.name === ".git") continue; const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { scanDir(fullPath, depth + 1); } else if (entry.isFile() && (entry.name.endsWith(".html") || entry.name.endsWith(".htm"))) { if (!entry.name.includes(".config.") && !entry.name.includes(".test.") && !entry.name.includes(".spec.")) { htmlCandidates.push({ dirPath: dir, filename: entry.name }); } } } } // Scope scan to the task's project directory when available to avoid // picking up stale HTML from unrelated projects. const scanRoot = taskProjectDir ? path.join(workspaceDir, taskProjectDir) : workspaceDir; if (taskProjectDir && !fs.existsSync(scanRoot)) { return null; } scanDir(scanRoot, 0); if (htmlCandidates.length === 0) { return null; } // Use OpenClaw workspace root as base for relative paths (worker paths and // preview manager both resolve relative to the OpenClaw workspace, not the // TeamClaw subdirectory). const candidate = htmlCandidates[0]; const relativeDir = path.relative(openclawWorkspaceDir, candidate.dirPath) || "."; const normalizedDir = relativeDir.replace(/\\/gu, "/"); // Avoid adding a duplicate if a directory deliverable for this path already exists const existingDir = contract.deliverables.find( (d) => d.kind === "directory" && d.value.replace(/\\/gu, "/").replace(/\/$/u, "") === normalizedDir, ); if (existingDir && existingDir.artifactType === "web-app" && existingWebApp?.previewCwd?.trim() !== ".") { // Existing web-app already has a specific, non-root directory — leave it alone. return null; } const newDeliverable: WorkerTaskResultDeliverable = { kind: "directory", value: normalizedDir, summary: `Web application at ${normalizedDir}`, artifactType: "web-app", previewCommand: "npx -y serve -l {PORT}", previewCwd: normalizedDir, previewReadyPath: "/", }; const newDeliverables = [...contract.deliverables]; if (existingDir) { // Update existing directory deliverable with web-app fields const idx = newDeliverables.indexOf(existingDir); // Always take the filesystem scan's directory path, which is more specific newDeliverables[idx] = { ...existingDir, ...newDeliverable }; } else { newDeliverables.push(newDeliverable); } return { ...contract, deliverables: newDeliverables }; } const MEANINGFUL_PROJECT_CHANGE_EXTENSIONS = new Set([ ".js", ".jsx", ".ts", ".tsx", ".json", ".html", ".css", ".scss", ".md", ".txt", ".yml", ".yaml", ".go", ".mod", ".sum", ".py", ".rb", ".rs", ".java", ".kt", ".swift", ".c", ".cc", ".cpp", ".h", ".hpp", ".cs", ".php", ".sh", ".bash", ".zsh", ".sql", ".toml", ".ini", ]); const IGNORED_PROJECT_CHANGE_DIRS = new Set([ "node_modules", ".git", "dist", "build", ".next", ".cache", "coverage", "tmp", "temp", ]); const IGNORED_PROJECT_CHANGE_FILES = new Set([ "package-lock.json", "pnpm-lock.yaml", "yarn.lock", ]); function taskRequiresMeaningfulProjectChangeGate(task: TaskInfo): boolean { if (task.assignedRole !== "developer" || !task.projectDir) { return false; } const text = `${task.title}\n${task.description}`.toLowerCase(); if (/\b(qa|audit|review|verify|verification|validate|test|retest|confirm|check)\b/u.test(text)) { return false; } if (/(审计|审核|验证|复检|复查|确认|测试|检查)/u.test(text)) { return false; } return /\b(implement|build|fix|rework|update|add|enhanc|deliver|write|create)\b/u.test(text); } function projectHasMeaningfulDeliverableEvidence( task: TaskInfo, contract: WorkerTaskResultContract | undefined, ): boolean { if (!task.projectDir || !contract) { return false; } const projectRoot = path.join(resolveTeamClawProjectsDir(), task.projectDir); for (const deliverable of contract.deliverables) { if (deliverable.kind !== "file" && deliverable.kind !== "directory") { continue; } const rawValue = deliverable.value.trim().replace(/\\/g, "/"); if (!rawValue) { continue; } const normalizedValue = rawValue.startsWith("projects/") ? rawValue.slice("projects/".length) : rawValue; if ( normalizedValue !== task.projectDir && !normalizedValue.startsWith(`${task.projectDir}/`) ) { continue; } const relativePath = normalizedValue === task.projectDir ? "" : normalizedValue.slice(task.projectDir.length + 1); const fullPath = relativePath ? path.join(projectRoot, relativePath) : projectRoot; try { const stats = fs.statSync(fullPath); if (stats.isFile()) { return true; } if (stats.isDirectory()) { const entries = fs.readdirSync(fullPath, { withFileTypes: true }); if (entries.some((entry) => entry.isFile() || entry.isDirectory())) { return true; } } } catch { continue; } } return false; } function projectHasMeaningfulFileChanges( task: TaskInfo, contract?: WorkerTaskResultContract, ): boolean { if (!task.projectDir) { return false; } const projectRoot = path.join(resolveTeamClawProjectsDir(), task.projectDir); if (!fs.existsSync(projectRoot)) { return false; } const threshold = Math.max(task.startedAt ?? 0, task.createdAt ?? 0) - 1000; const stack = [projectRoot]; while (stack.length > 0) { const currentDir = stack.pop()!; let entries: fs.Dirent[]; try { entries = fs.readdirSync(currentDir, { withFileTypes: true }); } catch { continue; } for (const entry of entries) { if (entry.isDirectory()) { if (!IGNORED_PROJECT_CHANGE_DIRS.has(entry.name)) { stack.push(path.join(currentDir, entry.name)); } continue; } if (!entry.isFile()) { continue; } if (IGNORED_PROJECT_CHANGE_FILES.has(entry.name)) { continue; } const ext = path.extname(entry.name).toLowerCase(); if (!MEANINGFUL_PROJECT_CHANGE_EXTENSIONS.has(ext)) { continue; } const fullPath = path.join(currentDir, entry.name); try { const stats = fs.statSync(fullPath); if (stats.mtimeMs >= threshold) { return true; } } catch { continue; } } } return projectHasMeaningfulDeliverableEvidence(task, contract); } function allowsNoChangeCompletion( task: TaskInfo, contract: WorkerTaskResultContract | undefined, resultText: string, ): boolean { const taskText = `${task.title}\n${task.description}`.toLowerCase(); const evidenceText = [ contract?.summary ?? "", contract?.notes ?? "", ...(contract?.keyPoints ?? []), resultText, ].join("\n").toLowerCase(); const hasVerificationEvidence = (contract?.deliverables ?? []).some((deliverable) => deliverable.kind === "command" || deliverable.kind === "note") || /\b(go test|go vet|npm test|pnpm test|yarn test|pytest|cargo test|verified|verification|passes|all tests pass|no remaining)\b/u.test(evidenceText); const noChangeIsExpected = /\b(qa|audit|review|verify|verification|validate|test|retest|confirm|check)\b/u.test(taskText) || /(审计|审核|验证|复检|复查|确认|测试|检查)/u.test(taskText) || /\b(already fixed|already resolved|no further changes|no code changes|no file changes|no additional changes|verified existing fix)\b/u.test(evidenceText) || /(已修复|已解决|无需改动|无需修改|没有额外修改|无需额外变更|只需验证)/u.test(evidenceText); return hasVerificationEvidence && noChangeIsExpected; } function applyTaskResult( taskId: string, result: string, error: string | undefined, deps: ControllerHttpDeps, ): TaskInfo | undefined { const { logger, updateTeamState, wsServer } = deps; let completionEvent: TaskExecutionEvent | undefined; const state = updateTeamState((s) => { const task = s.tasks[taskId]; if (!task) return; task.status = error ? "failed" : "completed"; task.result = result; task.error = error; task.completedAt = Date.now(); task.updatedAt = Date.now(); // Re-enrich deliverables now that the full result text is available. if (!error && task.resultContract) { // Filter out stale deliverables from other projects first task.resultContract = filterStaleDeliverables(task.resultContract, task.projectDir); let enriched = enrichDeliverablesWithPreviewInference(task.resultContract, result); if (!enriched) { // Text-based enrichment failed — scan the workspace filesystem for HTML files. enriched = enrichWithFilesystemHtmlScan(task.resultContract, task.projectDir); } if (enriched) { task.resultContract = enriched; } } completionEvent = appendTaskExecutionEvent(task, { type: error ? "error" : "lifecycle", phase: error ? "result_failed" : "result_completed", source: "controller", status: error ? "failed" : "completed", message: error ? `Task failed: ${error}` : "Task completed successfully.", workerId: task.assignedWorkerId, role: task.assignedRole, }); if (task.assignedWorkerId && s.workers[task.assignedWorkerId]) { const assignedWorker = s.workers[task.assignedWorkerId]; if (assignedWorker.status !== "offline") { assignedWorker.status = "idle"; } assignedWorker.currentTaskId = undefined; } }); const updatedTask = state.tasks[taskId]; if (updatedTask) { if (completionEvent) { broadcastTaskExecutionEvent(taskId, updatedTask, completionEvent, deps); } wsServer.broadcastUpdate({ type: "task:completed", data: serializeTask(updatedTask) }); logger.info(`Controller: task ${taskId} ${error ? "failed" : "completed"}`); if (error && updatedTask.assignedWorkerId && deps.workerProvisioningManager?.hasManagedWorker(updatedTask.assignedWorkerId)) { void deps.workerProvisioningManager.onWorkerRemoved( updatedTask.assignedWorkerId, `task ${taskId} failed; retiring managed worker before retry`, ).catch((err) => { logger.warn(`Controller: failed to retire managed worker ${updatedTask.assignedWorkerId}: ${String(err)}`); }); } if (updatedTask.assignedWorkerId) { void autoAssignPendingTasks(deps, updatedTask.assignedWorkerId).catch((err) => { logger.warn( `Controller: failed to auto-assign pending tasks after result for ${taskId}: ${String(err)}`, ); }); } scheduleProvisioningReconcile(deps, `task-result:${taskId}`); if (!error && updatedTask.createdBy === "controller") { void continueControllerWorkflow(updatedTask, deps).catch((err) => { logger.warn( `Controller: failed to continue intake workflow after ${taskId}: ${String(err)}`, ); }).finally(() => { // After the follow-up run completes (or if there was none), check session completion. void checkAndGenerateReport(updatedTask, deps).catch(() => {}); }); } else if (updatedTask.controllerSessionKey) { // Non-controller tasks or failed tasks — still check session completion. void checkAndGenerateReport(updatedTask, deps).catch(() => {}); } } return updatedTask; } async function requestTaskClarification(params: { taskId: string; requestedBy: string; requestedByWorkerId?: string; requestedByRole?: RoleId; question: string; blockingReason: string; context?: string; questionSchema?: ClarificationRequest["questionSchema"]; }, deps: ControllerHttpDeps): Promise<{ status: "created" | "already-pending" | "conflict" | "missing-task"; clarification?: ClarificationRequest; task?: TaskInfo; }> { const { getTeamState, updateTeamState, wsServer } = deps; const currentState = getTeamState(); const currentTask = currentState?.tasks[params.taskId]; if (!currentTask) { return { status: "missing-task" }; } if (currentTask.clarificationRequestId) { const existing = currentState?.clarifications[currentTask.clarificationRequestId]; if (existing?.status === "pending") { return { status: "already-pending", clarification: existing, task: currentTask }; } } if (currentTask.status === "completed" || currentTask.status === "failed") { return { status: "conflict", task: currentTask }; } const previousWorkerId = currentTask.assignedWorkerId; const clarificationId = generateId(); const now = Date.now(); const clarification: ClarificationRequest = { id: clarificationId, taskId: params.taskId, requestedBy: params.requestedBy, requestedByWorkerId: params.requestedByWorkerId, requestedByRole: params.requestedByRole, question: params.question, questionSchema: params.questionSchema, blockingReason: params.blockingReason, context: params.context, status: "pending", createdAt: now, updatedAt: now, }; const state = updateTeamState((s) => { s.clarifications[clarificationId] = clarification; const task = s.tasks[params.taskId]; if (!task) { return; } const assignedWorkerId = task.assignedWorkerId; task.status = "blocked"; task.progress = `Awaiting clarification: ${params.question}`; task.clarificationRequestId = clarificationId; task.assignedWorkerId = undefined; task.updatedAt = now; if (assignedWorkerId && s.workers[assignedWorkerId]) { const assignedWorker = s.workers[assignedWorkerId]; if (assignedWorker.status !== "offline") { assignedWorker.status = "idle"; } assignedWorker.currentTaskId = undefined; } }); await cancelTaskExecution(params.taskId, previousWorkerId, "clarification request", deps); const updatedTask = state.tasks[params.taskId]; wsServer.broadcastUpdate({ type: "clarification:requested", data: clarification }); if (updatedTask) { recordTaskExecutionEvent(params.taskId, { type: "lifecycle", phase: "clarification_requested", source: "controller", message: `Clarification requested: ${params.question}`, role: clarification.requestedByRole, workerId: clarification.requestedByWorkerId, }, deps); wsServer.broadcastUpdate({ type: "task:updated", data: serializeTask(updatedTask) }); } return { status: "created", clarification, task: updatedTask }; } function ensureTaskResultContract( taskId: string, result: string, error: string | undefined, deps: ControllerHttpDeps, ): WorkerTaskResultContract | undefined { const state = deps.getTeamState(); const currentTask = state?.tasks[taskId]; if (!currentTask) { return undefined; } if (currentTask.resultContract) { // Worker submitted a structured contract — but it may be missing preview // fields (artifactType, previewCommand, etc.). Enrich deliverables // so the PreviewManager can auto-launch previews. let enriched = enrichDeliverablesWithPreviewInference(currentTask.resultContract, result); if (!enriched) { // Text-based enrichment failed — scan the workspace filesystem for HTML files. enriched = enrichWithFilesystemHtmlScan(currentTask.resultContract, currentTask.projectDir); } if (enriched) { deps.updateTeamState((teamState) => { const task = teamState.tasks[taskId]; if (task) { task.resultContract = enriched; task.updatedAt = Date.now(); } }); return enriched; } return currentTask.resultContract; } let contract = backfillWorkerTaskResultContract(currentTask, result, error); if (!error) { const enriched = enrichDeliverablesWithPreviewInference(contract, result) ?? enrichWithFilesystemHtmlScan(contract, currentTask.projectDir); if (enriched) { contract = enriched; } } deps.updateTeamState((teamState) => { const task = teamState.tasks[taskId]; if (!task || task.resultContract) { return; } task.resultContract = contract; }); recordTaskExecutionEvent(taskId, { type: "lifecycle", phase: "result_contract_backfilled", source: "controller", message: "Worker did not submit a structured result contract; TeamClaw backfilled one from the recorded task result.", workerId: currentTask.assignedWorkerId, role: currentTask.assignedRole, }, deps); return contract; } function buildEffectiveTaskResultContract( task: TaskInfo, result: string, error: string | undefined, submittedContract?: WorkerTaskResultContract, ): WorkerTaskResultContract { let contract = submittedContract ? filterStaleDeliverables(submittedContract, task.projectDir) : backfillWorkerTaskResultContract(task, result, error); if (!error) { const enriched = enrichDeliverablesWithPreviewInference(contract, result) ?? enrichWithFilesystemHtmlScan(contract, task.projectDir); if (enriched) { contract = enriched; } } return contract; } function buildResultContractSection(task: TaskInfo): string { const contract = task.resultContract; if (!contract) { return ""; } const lines = [ "## Structured Result Contract", `Outcome: ${contract.outcome}`, `Summary: ${contract.summary}`, ]; if (contract.deliverables.length > 0) { lines.push("Deliverables:"); for (const deliverable of contract.deliverables) { const summary = deliverable.summary ? ` — ${deliverable.summary}` : ""; lines.push(`- ${deliverable.kind}: ${deliverable.value}${summary}`); } } if (contract.keyPoints.length > 0) { lines.push("Key points:"); for (const keyPoint of contract.keyPoints) { lines.push(`- ${keyPoint}`); } } if (contract.blockers.length > 0) { lines.push("Blockers:"); for (const blocker of contract.blockers) { lines.push(`- ${blocker}`); } } if (contract.followUps.length > 0) { lines.push("Suggested follow-ups:"); for (const followUp of contract.followUps) { const roleLabel = followUp.targetRole ? ` (${followUp.targetRole})` : ""; lines.push(`- ${followUp.type}${roleLabel}: ${followUp.reason}`); } } if (contract.questions.length > 0) { lines.push("Open questions:"); for (const question of contract.questions) { lines.push(`- ${question}`); } } if (contract.notes) { lines.push(`Notes: ${contract.notes}`); } if (contract.discoveredPatterns && contract.discoveredPatterns.length > 0) { lines.push("Discovered patterns:"); for (const pattern of contract.discoveredPatterns) { lines.push(`- ${pattern}`); } } return lines.join("\n"); } const MAX_PATTERNS_FILE_BYTES = 64 * 1024; function consolidateDiscoveredPatterns( contract: WorkerTaskResultContract, taskId: string, role: string | undefined, logger: PluginLogger, ): void { const patterns = contract.discoveredPatterns; if (!patterns || patterns.length === 0) { return; } try { const workspaceDir = resolveTeamClawWorkspaceDir(); const patternsFile = path.join(workspaceDir, "memory", "patterns.md"); fs.mkdirSync(path.join(workspaceDir, "memory"), { recursive: true }); let currentSize = 0; try { currentSize = fs.statSync(patternsFile).size; } catch { // File doesn't exist yet; will be created by append. } if (currentSize > MAX_PATTERNS_FILE_BYTES) { logger.info(`Controller: patterns.md already ${currentSize} bytes, skipping consolidation for ${taskId}`); return; } const roleLabel = role ?? "unknown"; const timestamp = new Date().toISOString().slice(0, 10); const section = [ "", `## ${timestamp} — ${taskId} (${roleLabel})`, ...patterns.map((p) => `- ${p}`), ].join("\n") + "\n"; fs.appendFileSync(patternsFile, section, "utf8"); logger.info(`Controller: consolidated ${patterns.length} pattern(s) from ${taskId} into patterns.md`); } catch (err) { logger.warn(`Controller: failed to consolidate patterns for ${taskId}: ${err instanceof Error ? err.message : String(err)}`); } } function buildConsolidatedPatternsContext(): string { try { const workspaceDir = resolveTeamClawWorkspaceDir(); const patternsFile = path.join(workspaceDir, "memory", "patterns.md"); const content = fs.readFileSync(patternsFile, "utf8").trim(); // Only inject if there are actual patterns (more than just the header) const lines = content.split("\n").filter((l: string) => l.startsWith("- ")); if (lines.length === 0) { return ""; } return [ "## Discovered Codebase Patterns", "Previous workers discovered these reusable patterns. Apply them where relevant:", ...lines, ].join("\n"); } catch { return ""; } } function revertTaskAssignment(taskId: string, workerId: string, deps: ControllerHttpDeps): TaskInfo | undefined { const { updateTeamState, wsServer } = deps; let revertEvent: TaskExecutionEvent | undefined; const state = updateTeamState((s) => { const task = s.tasks[taskId]; if (!task) { return; } if (task.assignedWorkerId === workerId) { task.status = "pending"; task.assignedWorkerId = undefined; task.updatedAt = Date.now(); revertEvent = appendTaskExecutionEvent(task, { type: "error", phase: "assignment_reverted", source: "controller", message: `Assignment to ${workerId} was reverted; task returned to pending.`, }); } const worker = s.workers[workerId]; if (worker?.currentTaskId === taskId) { if (worker.status !== "offline") { worker.status = "idle"; } worker.currentTaskId = undefined; } }); const updatedTask = state.tasks[taskId]; if (updatedTask) { if (revertEvent) { broadcastTaskExecutionEvent(taskId, updatedTask, revertEvent, deps); } wsServer.broadcastUpdate({ type: "task:updated", data: serializeTask(updatedTask) }); void autoAssignPendingTasks(deps).catch(() => { // Best-effort retry path; assignment failure is already surfaced via task state. }); scheduleProvisioningReconcile(deps, `assignment-reverted:${taskId}`); } return updatedTask; } async function deliverMessageToWorker( worker: WorkerInfo, message: TeamMessage, deps: ControllerHttpDeps, ): Promise { const res = await fetch(`${worker.url}/api/v1/messages`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(message), }); if (!res.ok) { throw new Error(`worker ${worker.id} responded with ${res.status}`); } } async function routeDirectMessage( message: TeamMessage, deps: ControllerHttpDeps, ): Promise { const { getTeamState, logger, messageRouter } = deps; const state = getTeamState(); if (!state) { return false; } const routed = messageRouter.routeDirectMessage(message, state.workers); if (!routed) { return false; } try { await deliverMessageToWorker(routed.worker, routed.message, deps); } catch (err) { logger.warn(`Controller: failed to deliver message to ${routed.worker.id}: ${String(err)}`); } return true; } async function dispatchTaskToWorker( taskId: string, worker: WorkerInfo, deps: ControllerHttpDeps, ): Promise { const { getTeamState } = deps; const state = getTeamState(); const task = state?.tasks[taskId]; if (!task) { throw new Error(`task ${taskId} not found`); } // Ensure project directory exists if (task.projectDir) { const projectPath = path.join(resolveTeamClawProjectsDir(), task.projectDir); try { fs.mkdirSync(projectPath, { recursive: true }); } catch { /* best-effort */ } } const sharedWorkspace = deps.workerProvisioningManager?.isSharedWorkspaceWorker(worker.id) || false; const repoState = await refreshControllerRepoState(deps); const repoInfo = buildRepoSyncInfo(repoState, sharedWorkspace); const description = buildTaskAssignmentDescription(task, state ?? null, repoInfo); const recommendedSkills = resolveRecommendedSkillsForRole(task.assignedRole, task.recommendedSkills ?? []); const executionIdentity = buildTaskExecutionIdentity(task.id, worker.id); const assignment: TaskAssignmentPayload = { taskId: task.id, title: task.title, description, priority: task.priority, recommendedSkills, projectDir: task.projectDir, executionSessionKey: executionIdentity.executionSessionKey, executionIdempotencyKey: executionIdentity.executionIdempotencyKey, repo: repoInfo, teamContext: buildTaskContextSnapshot(task, state ?? null), }; const res = await fetch(`${worker.url}/api/v1/tasks/assign`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(assignment), }); if (!res.ok) { throw new Error(`worker ${worker.id} responded with ${res.status}`); } } async function assignTaskToWorker( taskId: string, worker: WorkerInfo, deps: ControllerHttpDeps, options?: { assignedRole?: RoleId; }, ): Promise { const { logger, updateTeamState } = deps; let assignmentApplied = false; updateTeamState((s) => { const task = s.tasks[taskId]; const targetWorker = s.workers[worker.id]; if (!task || !targetWorker) { return; } if (targetWorker.status !== "idle") { return; } const canAssignCurrentTask = task.status === "pending" || (task.status === "assigned" && task.assignedWorkerId === worker.id); if (!canAssignCurrentTask) { return; } task.status = "assigned"; task.assignedWorkerId = worker.id; if (options?.assignedRole) { task.assignedRole = options.assignedRole; } resetTaskForFreshAttempt(task); task.updatedAt = Date.now(); targetWorker.status = "busy"; targetWorker.currentTaskId = taskId; assignmentApplied = true; }); if (!assignmentApplied) { return deps.getTeamState()?.tasks[taskId]; } try { await dispatchTaskToWorker(taskId, worker, deps); } catch (err) { logger.warn(`Controller: failed to dispatch task ${taskId} to ${worker.id}: ${String(err)}`); recordTaskExecutionEvent(taskId, { type: "error", phase: "dispatch_failed", source: "controller", message: `Failed to dispatch task to ${worker.id}: ${String(err)}`, workerId: worker.id, role: options?.assignedRole ?? worker.role, }, deps); return revertTaskAssignment(taskId, worker.id, deps); } recordTaskExecutionEvent(taskId, { type: "lifecycle", phase: "assigned", source: "controller", message: `Assigned to ${worker.label || worker.id}.`, workerId: worker.id, role: options?.assignedRole ?? worker.role, }, deps); return deps.getTeamState()?.tasks[taskId]; } async function autoAssignPendingTasks( deps: ControllerHttpDeps, preferredWorkerId?: string, ): Promise { const { getTeamState, taskRouter, wsServer, logger } = deps; const attemptedPairs = new Set(); const assignedTasks: TaskInfo[] = []; while (true) { const state = getTeamState(); if (!state) { break; } const candidateAssignments = taskRouter .autoAssignPendingTasks(state.tasks, state.workers) .filter(({ task, worker }) => !attemptedPairs.has(`${task.id}:${worker.id}`)); const nextAssignment = ( (preferredWorkerId ? candidateAssignments.find(({ worker }) => worker.id === preferredWorkerId) : undefined) ?? candidateAssignments[0] ); if (!nextAssignment) { scheduleProvisioningReconcile(deps, preferredWorkerId ? `auto-assign-wait:${preferredWorkerId}` : "auto-assign-wait"); break; } const pairKey = `${nextAssignment.task.id}:${nextAssignment.worker.id}`; attemptedPairs.add(pairKey); const updatedTask = await assignTaskToWorker(nextAssignment.task.id, nextAssignment.worker, deps, { assignedRole: nextAssignment.task.assignedRole, }); if (updatedTask?.status === "assigned" && updatedTask.assignedWorkerId === nextAssignment.worker.id) { wsServer.broadcastUpdate({ type: "task:updated", data: serializeTask(updatedTask) }); logger.info( `Controller: auto-assigned pending task ${updatedTask.id} to ${nextAssignment.worker.id}`, ); assignedTasks.push(updatedTask); } } scheduleProvisioningReconcile(deps, preferredWorkerId ? `auto-assign:${preferredWorkerId}` : "auto-assign"); return assignedTasks; } export function createControllerHttpServer(deps: ControllerHttpDeps): http.Server { const { logger, wsServer } = deps; const server = http.createServer(async (req: IncomingMessage, res: ServerResponse) => { // CORS preflight if (req.method === "OPTIONS") { res.writeHead(200, { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE, OPTIONS", "Access-Control-Allow-Headers": "Content-Type", }); res.end(); return; } const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`); const pathname = url.pathname; try { await handleRequest(req, res, pathname, deps); } catch (err) { logger.error(`Controller HTTP error: ${err instanceof Error ? err.message : String(err)}`); sendError(res, 500, "Internal server error"); } }); // Attach WebSocket wsServer.attach(server); setTimeout(() => { const state = deps.getTeamState(); const pendingCount = state ? Object.values(state.tasks).filter((t) => t.status === "pending" && !t.assignedWorkerId).length : 0; if (pendingCount > 0) { logger.info(`Controller: startup reconciliation — ${pendingCount} pending tasks, triggering auto-assign`); void autoAssignPendingTasks(deps).catch((err) => { logger.warn(`Controller: startup auto-assign failed: ${String(err)}`); }); } }, 2000); return server; } async function handleRequest( req: IncomingMessage, res: ServerResponse, pathname: string, deps: ControllerHttpDeps, ): Promise { const { config, logger, getTeamState, updateTeamState, taskRouter, messageRouter, wsServer } = deps; const requestUrl = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`); const currentState = getTeamState(); // ==================== Web UI ==================== if (req.method === "GET" && pathname === "/") { res.statusCode = 302; res.setHeader("Location", "/ui"); res.end(); return; } if (req.method === "GET" && (pathname === "/ui" || pathname === "/ui/")) { const uiPath = path.join(import.meta.dirname, "..", "ui"); serveStaticFile(res, path.join(uiPath, "index.html"), "text/html; charset=utf-8"); return; } if (req.method === "GET" && pathname.startsWith("/ui/")) { const uiPath = path.join(import.meta.dirname, "..", "ui"); const file = pathname.slice(4); // remove "/ui/" if (file.endsWith(".css")) { serveStaticFile(res, path.join(uiPath, file), "text/css; charset=utf-8"); } else if (file.endsWith(".js")) { serveStaticFile(res, path.join(uiPath, file), "application/javascript; charset=utf-8"); } else if (file.endsWith(".png")) { serveStaticFile(res, path.join(uiPath, file), "image/png"); } else if (file.endsWith(".svg")) { serveStaticFile(res, path.join(uiPath, file), "image/svg+xml; charset=utf-8"); } else if (file.endsWith(".ico")) { serveStaticFile(res, path.join(uiPath, file), "image/x-icon"); } else { serveStaticFile(res, path.join(uiPath, file), "application/octet-stream"); } return; } // ==================== Workspace Browser ==================== if (req.method === "GET" && pathname === "/api/v1/workspace/tree") { try { // depth=N limits tree expansion (default 1 for lazy loading) const depthParam = requestUrl.searchParams.get("depth"); const maxDepth = depthParam !== null ? Math.max(0, Math.min(parseInt(depthParam, 10) || 1, 8)) : 1; sendJson(res, 200, await listWorkspaceTree(maxDepth)); } catch (err) { sendError(res, workspaceRequestErrorStatus(err), workspaceRequestErrorMessage(err)); } return; } if (req.method === "GET" && pathname === "/api/v1/workspace/subtree") { const dirPath = requestUrl.searchParams.get("path") ?? ""; if (!dirPath) { sendError(res, 400, "path is required"); return; } try { sendJson(res, 200, { entries: await listWorkspaceSubtree(dirPath) }); } catch (err) { sendError(res, workspaceRequestErrorStatus(err), workspaceRequestErrorMessage(err)); } return; } if (req.method === "GET" && pathname === "/api/v1/workspace/file") { const relativePath = requestUrl.searchParams.get("path") ?? ""; if (!relativePath) { sendError(res, 400, "path is required"); return; } try { sendJson(res, 200, { file: await readWorkspaceFile(relativePath) }); } catch (err) { sendError(res, workspaceRequestErrorStatus(err), workspaceRequestErrorMessage(err)); } return; } if (req.method === "GET" && pathname.startsWith("/api/v1/workspace/raw/")) { const rawPathname = pathname.slice("/api/v1/workspace/raw/".length); if (!rawPathname) { sendError(res, 400, "path is required"); return; } try { const relativePath = decodeURIComponent(rawPathname); const file = await readWorkspaceRawFile(relativePath); res.writeHead(200, { "Content-Type": file.contentType, "Cache-Control": "no-store", "Access-Control-Allow-Origin": "*", "X-Content-Type-Options": "nosniff", }); res.end(file.content); } catch (err) { sendError(res, workspaceRequestErrorStatus(err), workspaceRequestErrorMessage(err)); } return; } // ==================== Worker Management ==================== // POST /api/v1/workers/register if (req.method === "POST" && pathname === "/api/v1/workers/register") { const body = await parseJsonBody(req); const workerId = typeof body.workerId === "string" ? body.workerId : ""; const role = typeof body.role === "string" ? body.role as RoleId : ""; const label = typeof body.label === "string" ? body.label : role; const workerUrl = typeof body.url === "string" ? body.url : ""; const capabilities = Array.isArray(body.capabilities) ? body.capabilities as string[] : []; const launchToken = typeof body.launchToken === "string" ? body.launchToken : undefined; if (!workerId || !role || !workerUrl) { sendError(res, 400, "workerId, role, and url are required"); return; } const registrationValidation = deps.workerProvisioningManager?.validateRegistration(workerId, role, launchToken); if (registrationValidation && !registrationValidation.ok) { sendError(res, 403, registrationValidation.reason ?? "Worker registration rejected"); return; } const state = updateTeamState((s) => { s.workers[workerId] = { id: workerId, role, label, status: "idle", transport: "http", url: workerUrl, lastHeartbeat: Date.now(), capabilities, registeredAt: Date.now(), }; }); deps.workerProvisioningManager?.onWorkerRegistered(workerId); wsServer.broadcastUpdate({ type: "worker:online", data: state.workers[workerId] }); logger.info(`Controller: worker registered - ${label} (${workerId}) at ${workerUrl}`); sendJson(res, 201, { status: "registered", worker: state.workers[workerId] }); void autoAssignPendingTasks(deps, workerId).catch((err) => { logger.warn(`Controller: failed to auto-assign after worker registration (${workerId}): ${String(err)}`); }); return; } // DELETE /api/v1/workers/:id if (req.method === "DELETE" && pathname.match(/^\/api\/v1\/workers\/[^/]+$/)) { const workerId = pathname.split("/").pop()!; if (deps.workerProvisioningManager?.hasManagedWorker(workerId)) { await deps.workerProvisioningManager.onWorkerRemoved(workerId, "worker delete requested"); } const affectedTaskIds: string[] = []; updateTeamState((s) => { const worker = s.workers[workerId]; if (worker) { worker.status = "offline"; worker.currentTaskId = undefined; delete s.workers[workerId]; } for (const task of Object.values(s.tasks)) { if ( task.assignedWorkerId === workerId && task.status !== "completed" && task.status !== "failed" && task.status !== "blocked" ) { task.status = "pending"; task.assignedWorkerId = undefined; resetTaskForFreshAttempt(task); task.updatedAt = Date.now(); affectedTaskIds.push(task.id); } } }); await autoAssignPendingTasks(deps); for (const taskId of affectedTaskIds) { const task = getTeamState()?.tasks[taskId]; if (task) { wsServer.broadcastUpdate({ type: "task:updated", data: serializeTask(task) }); } } wsServer.broadcastUpdate({ type: "worker:offline", data: { workerId } }); logger.info(`Controller: worker removed - ${workerId}`); sendJson(res, 200, { status: "removed" }); return; } // GET /api/v1/workers if (req.method === "GET" && pathname === "/api/v1/workers") { const state = getTeamState(); const workers = state ? Object.values(state.workers) : []; sendJson(res, 200, { workers }); return; } // POST /api/v1/workers/:id/heartbeat if (req.method === "POST" && pathname.match(/^\/api\/v1\/workers\/[^/]+\/heartbeat$/)) { const workerId = pathname.split("/")[4]!; const body = await parseJsonBody(req); const status = typeof body.status === "string" ? body.status as WorkerInfo["status"] : "idle"; const currentTaskId = typeof body.currentTaskId === "string" ? body.currentTaskId : undefined; updateTeamState((s) => { if (s.workers[workerId]) { s.workers[workerId].lastHeartbeat = Date.now(); s.workers[workerId].status = status; s.workers[workerId].currentTaskId = currentTaskId; } }); deps.workerProvisioningManager?.onWorkerHeartbeat(workerId, status); if (status === "idle") { await autoAssignPendingTasks(deps, workerId); } else { scheduleProvisioningReconcile(deps, `heartbeat:${workerId}:${status}`); } sendJson(res, 200, { status: "ok" }); return; } // ==================== Task Management ==================== // POST /api/v1/tasks if (req.method === "POST" && pathname === "/api/v1/tasks") { const body = await parseJsonBody(req); const title = typeof body.title === "string" ? body.title : ""; const description = typeof body.description === "string" ? body.description : ""; const priority = typeof body.priority === "string" ? body.priority as TaskPriority : "medium"; const assignedRole = typeof body.assignedRole === "string" ? body.assignedRole as RoleId : undefined; const createdBy = typeof body.createdBy === "string" ? body.createdBy : "boss"; const controllerSessionKey = createdBy === "controller" && typeof body.controllerSessionKey === "string" && body.controllerSessionKey.trim() ? normalizeControllerIntakeSessionKey(body.controllerSessionKey) : undefined; const explicitProjectName = typeof body.projectName === "string" && body.projectName.trim() ? deriveStableProjectKey(body.projectName) : undefined; const recommendedSkills = normalizeRecommendedSkills( Array.isArray(body.recommendedSkills) ? body.recommendedSkills.map((entry) => String(entry ?? "")) : [], ); if (!title) { sendError(res, 400, "title is required"); return; } if (createdBy === "controller") { const readinessGate = shouldBlockControllerUntilProvisioningReady(deps, currentState); if (readinessGate.blocked && readinessGate.readiness) { sendError(res, 503, buildStartupReadinessMessage(readinessGate.readiness)); return; } } if (createdBy === "controller" && shouldBlockControllerWithoutWorkers(deps.config, getTeamState())) { sendError(res, 409, buildControllerNoWorkersMessage()); return; } const taskId = generateId(); const now = Date.now(); const repoState = await refreshControllerRepoState(deps); const inheritedProject = controllerSessionKey ? resolveProjectIdentityForSession(controllerSessionKey, getTeamState()) : undefined; const projectId = inheritedProject?.projectId ?? (explicitProjectName || undefined); const projectDir = inheritedProject?.projectDir ?? projectId ?? deriveProjectSlug(title); const task: TaskInfo = { id: taskId, title, description, status: "pending", priority, assignedRole, createdBy, recommendedSkills: recommendedSkills.length > 0 ? recommendedSkills : undefined, controllerSessionKey, projectId, projectDir, createdAt: now, updatedAt: now, }; updateTeamState((s) => { const project = syncProjectRegistryEntry(s, { projectId: task.projectId, projectDir: task.projectDir, aliases: [explicitProjectName, task.projectId], summary: task.title, updatedAt: now, }); task.projectId = project.projectId; task.projectDir = project.projectDir; s.tasks[taskId] = task; }); recordTaskExecutionEvent(taskId, { type: "lifecycle", phase: "created", source: "controller", status: "pending", message: `Task created by ${createdBy}.`, role: assignedRole, }, deps); if (repoState?.enabled) { recordTaskExecutionEvent(taskId, { type: "lifecycle", phase: "repo_ready", source: "controller", status: "pending", message: repoState.remoteReady && repoState.remoteUrl ? `Git collaboration ready on ${repoState.defaultBranch} with remote ${repoState.remoteUrl}.` : `Git collaboration ready on ${repoState.defaultBranch} using controller-managed bundle sync.`, role: assignedRole, }, deps); } if (recommendedSkills.length > 0) { recordTaskExecutionEvent(taskId, { type: "lifecycle", phase: "skills_recommended", source: "controller", status: "pending", message: `Recommended skills: ${recommendedSkills.join(", ")}`, role: assignedRole, }, deps); } await autoAssignPendingTasks(deps); const updatedTask = getTeamState()?.tasks[taskId]; wsServer.broadcastUpdate({ type: "task:created", data: serializeTask(updatedTask) }); sendJson(res, 201, { task: serializeTask(updatedTask) }); return; } // GET /api/v1/tasks if (req.method === "GET" && pathname === "/api/v1/tasks") { const state = getTeamState(); const tasks = state ? Object.values(state.tasks).map((task) => serializeTask(task)) : []; sendJson(res, 200, { tasks }); return; } // GET /api/v1/tasks/:id if (req.method === "GET" && pathname.match(/^\/api\/v1\/tasks\/[^/]+$/)) { const taskId = pathname.split("/").pop()!; const state = getTeamState(); const task = state?.tasks[taskId]; if (!task) { sendError(res, 404, "Task not found"); return; } sendJson(res, 200, { task: serializeTask(task) }); return; } // GET /api/v1/tasks/:id/execution if (req.method === "GET" && pathname.match(/^\/api\/v1\/tasks\/[^/]+\/execution$/)) { const taskId = pathname.split("/")[4]!; const state = getTeamState(); const task = state?.tasks[taskId]; if (!task) { sendError(res, 404, "Task not found"); return; } const clarifications = state ? Object.values(state.clarifications) .filter((item) => item.taskId === taskId) .sort((left, right) => left.createdAt - right.createdAt) : []; const messages = state ? state.messages .filter((message) => message.taskId === taskId) .sort((left, right) => left.createdAt - right.createdAt) : []; sendJson(res, 200, { task: serializeTask(task, true), messages, clarifications, }); return; } // PATCH /api/v1/tasks/:id if (req.method === "PATCH" && pathname.match(/^\/api\/v1\/tasks\/[^/]+$/)) { const taskId = pathname.split("/").pop()!; const body = await parseJsonBody(req); let statusEvent: TaskExecutionEvent | undefined; let progressEvent: TaskExecutionEvent | undefined; let progressContract: WorkerProgressContract | undefined; const state = updateTeamState((s) => { const task = s.tasks[taskId]; if (!task) return; const previousStatus = task.status; const previousProgress = task.progress; const previousProgressContract = task.progressContract; if (typeof body.status === "string") task.status = body.status as TaskStatus; if (typeof body.progress === "string") task.progress = body.progress as string; if (typeof body.priority === "string") task.priority = body.priority as TaskPriority; if (typeof body.assignedRole === "string") task.assignedRole = body.assignedRole as RoleId; if (Array.isArray(body.recommendedSkills)) { const recommendedSkills = normalizeRecommendedSkills( body.recommendedSkills.map((entry: unknown) => String(entry ?? "")), ); task.recommendedSkills = recommendedSkills.length > 0 ? recommendedSkills : undefined; } progressContract = normalizeWorkerProgressContract(body.progressContract) ?? (typeof body.progress === "string" ? backfillWorkerProgressContract(body.progress, typeof body.status === "string" ? body.status : undefined) : undefined); if (progressContract) { task.progressContract = progressContract; task.progress = task.progress || progressContract.summary; } task.updatedAt = Date.now(); if (typeof body.status === "string" && body.status !== previousStatus) { statusEvent = appendTaskExecutionEvent(task, { type: "lifecycle", phase: `status_${task.status}`, source: "controller", status: mapTaskStatusToExecutionStatus(task.status, task.execution?.status), message: `Task status updated to ${task.status}.`, }); } if (typeof body.progress === "string" && body.progress !== previousProgress) { progressEvent = appendTaskExecutionEvent(task, { type: "progress", phase: "progress_reported", source: "worker", status: task.status === "in_progress" || task.status === "review" ? "running" : undefined, message: body.progress as string, }); } else if (progressContract && JSON.stringify(progressContract) !== JSON.stringify(previousProgressContract)) { progressEvent = appendTaskExecutionEvent(task, { type: "progress", phase: "progress_contract_reported", source: "worker", status: task.status === "in_progress" || task.status === "review" ? "running" : undefined, message: progressContract.summary, }); } }); const updatedTask = state.tasks[taskId]; if (updatedTask) { if (statusEvent) { broadcastTaskExecutionEvent(taskId, updatedTask, statusEvent, deps); } if (progressEvent) { broadcastTaskExecutionEvent(taskId, updatedTask, progressEvent, deps); } wsServer.broadcastUpdate({ type: "task:updated", data: serializeTask(updatedTask) }); } sendJson(res, 200, { task: serializeTask(updatedTask) }); return; } // POST /api/v1/tasks/:id/assign if (req.method === "POST" && pathname.match(/^\/api\/v1\/tasks\/[^/]+\/assign$/)) { const taskId = pathname.split("/")[4]!; const body = await parseJsonBody(req); const workerId = typeof body.workerId === "string" ? body.workerId : undefined; const targetRole = typeof body.targetRole === "string" ? body.targetRole as RoleId : undefined; const state = getTeamState(); if (!state?.tasks[taskId]) { sendError(res, 404, "Task not found"); return; } let targetWorker: WorkerInfo | null = null; if (workerId && state.workers[workerId]) { targetWorker = state.workers[workerId]!; } else { const taskForRouting = targetRole ? { ...state.tasks[taskId], assignedRole: targetRole } : state.tasks[taskId]; targetWorker = taskRouter.routeTask(taskForRouting, state.workers); } if (!targetWorker) { sendError(res, 404, "No available worker for this task"); return; } const updatedTask = await assignTaskToWorker(taskId, targetWorker, deps, { assignedRole: targetRole, }); wsServer.broadcastUpdate({ type: "task:updated", data: serializeTask(updatedTask) }); sendJson(res, 200, { task: serializeTask(updatedTask), worker: targetWorker }); return; } // POST /api/v1/tasks/:id/handoff if (req.method === "POST" && pathname.match(/^\/api\/v1\/tasks\/[^/]+\/handoff$/)) { const taskId = pathname.split("/")[4]!; const body = await parseJsonBody(req); const targetRole = typeof body.targetRole === "string" ? body.targetRole as RoleId : undefined; const handoffContract = normalizeTaskHandoffContract(body.contract, { targetRole, reason: typeof body.reason === "string" ? body.reason : targetRole ? `The next step should move to ${targetRole}.` : "The task needs a new assignee.", summary: typeof body.summary === "string" ? body.summary : undefined, expectedNextStep: typeof body.expectedNextStep === "string" ? body.expectedNextStep : undefined, artifacts: [], }); const state = getTeamState(); if (!state?.tasks[taskId]) { sendError(res, 404, "Task not found"); return; } const previousWorkerId = state.tasks[taskId].assignedWorkerId; const avoidPreviousManagedWorker = Boolean( previousWorkerId && deps.workerProvisioningManager?.hasManagedWorker(previousWorkerId), ); updateTeamState((s) => { const task = s.tasks[taskId]; task.status = "pending"; task.assignedWorkerId = undefined; task.assignedRole = targetRole ?? task.assignedRole; task.lastHandoff = handoffContract; resetTaskForFreshAttempt(task); task.updatedAt = Date.now(); // Free old worker if (previousWorkerId && s.workers[previousWorkerId]) { const previousWorker = s.workers[previousWorkerId]; if (previousWorker.status !== "offline") { previousWorker.status = "idle"; } previousWorker.currentTaskId = undefined; } }); await cancelTaskExecution(taskId, previousWorkerId, "handoff", deps); if (avoidPreviousManagedWorker && previousWorkerId) { try { await deps.workerProvisioningManager?.onWorkerRemoved( previousWorkerId, `handoff for ${taskId} requested a fresh managed worker`, ); } catch (err) { logger.warn(`Controller: failed to retire previous managed worker ${previousWorkerId}: ${String(err)}`); } } // Try auto-assign to new role const newState = getTeamState()!; const routingWorkers = avoidPreviousManagedWorker && previousWorkerId ? Object.fromEntries(Object.entries(newState.workers).filter(([workerId]) => workerId !== previousWorkerId)) : newState.workers; const worker = taskRouter.routeTask(newState.tasks[taskId], routingWorkers); if (worker) { await assignTaskToWorker(taskId, worker, deps, { assignedRole: targetRole }); } else { scheduleProvisioningReconcile(deps, `handoff:${taskId}`); } const updatedTask = getTeamState()?.tasks[taskId]; recordTaskExecutionEvent(taskId, { type: "lifecycle", phase: "handoff", source: "controller", message: targetRole ? `Task handed off and re-routed to role ${targetRole}: ${handoffContract.summary}` : `Task handed off for re-routing: ${handoffContract.summary}`, role: targetRole, }, deps); wsServer.broadcastUpdate({ type: "task:updated", data: serializeTask(updatedTask) }); sendJson(res, 200, { task: serializeTask(updatedTask) }); return; } // POST /api/v1/tasks/:id/result-contract if (req.method === "POST" && pathname.match(/^\/api\/v1\/tasks\/[^/]+\/result-contract$/)) { const taskId = pathname.split("/")[4]!; const body = await parseJsonBody(req); const contract = normalizeWorkerTaskResultContract(body.contract ?? body.resultContract); const workerId = typeof body.workerId === "string" ? body.workerId : undefined; const currentTask = getTeamState()?.tasks[taskId]; if (!currentTask) { sendError(res, 404, "Task not found"); return; } if (!contract) { sendError(res, 400, "result contract is required"); return; } if (workerId && !canAcceptWorkerUpdate(currentTask, workerId)) { logger.info(`Controller: ignoring stale result contract for ${taskId} from ${workerId}`); sendJson(res, 202, { status: "ignored", reason: "stale-worker-result-contract" }); return; } const state = updateTeamState((teamState) => { const task = teamState.tasks[taskId]; if (!task) { return; } task.resultContract = filterStaleDeliverables(contract, task.projectDir); task.updatedAt = Date.now(); // Enrich deliverables with preview inference so PreviewManager can auto-launch. // Workers typically don't include artifactType/previewCommand in their contracts. const existingResult = task.result ?? ""; let enriched = enrichDeliverablesWithPreviewInference(task.resultContract, existingResult); if (!enriched) { enriched = enrichWithFilesystemHtmlScan(contract, task.projectDir); } if (enriched) { task.resultContract = enriched; } }); recordTaskExecutionEvent(taskId, { type: "output", phase: "result_contract_recorded", source: "worker", status: contract.outcome === "failed" ? "failed" : "running", message: contract.summary, workerId, role: currentTask.assignedRole, }, deps); consolidateDiscoveredPatterns(contract, taskId, currentTask.assignedRole, logger); sendJson(res, 201, { task: serializeTask(state.tasks[taskId]) }); return; } // POST /api/v1/tasks/:id/result if (req.method === "POST" && pathname.match(/^\/api\/v1\/tasks\/[^/]+\/result$/)) { const taskId = pathname.split("/")[4]!; const body = await parseJsonBody(req); const result = typeof body.result === "string" ? body.result : ""; let error = typeof body.error === "string" ? body.error : undefined; const workerId = typeof body.workerId === "string" ? body.workerId : undefined; const currentTask = getTeamState()?.tasks[taskId]; if (!currentTask) { sendError(res, 404, "Task not found"); return; } if (workerId && !canAcceptWorkerUpdate(currentTask, workerId)) { logger.info(`Controller: ignoring stale task result for ${taskId} from ${workerId}`); sendJson(res, 202, { status: "ignored", reason: "stale-worker-result" }); return; } const previousWorkerId = getTeamState()?.tasks[taskId]?.assignedWorkerId; const submittedContract = normalizeWorkerTaskResultContract(body.contract ?? body.resultContract); if (submittedContract) { updateTeamState((teamState) => { const task = teamState.tasks[taskId]; if (!task) { return; } task.resultContract = submittedContract; }); recordTaskExecutionEvent(taskId, { type: "output", phase: "result_contract_recorded", source: "worker", status: error ? "failed" : "running", message: submittedContract.summary, workerId, role: currentTask.assignedRole, }, deps); } const effectiveContract = buildEffectiveTaskResultContract(currentTask, result, error, submittedContract); if (!error && effectiveContract.outcome === "blocked") { updateTeamState((teamState) => { const task = teamState.tasks[taskId]; if (!task) { return; } task.resultContract = effectiveContract; task.updatedAt = Date.now(); }); if (!submittedContract) { recordTaskExecutionEvent(taskId, { type: "lifecycle", phase: "result_contract_backfilled", source: "controller", message: "Worker did not submit a structured result contract; TeamClaw backfilled one from the recorded task result.", workerId: currentTask.assignedWorkerId, role: currentTask.assignedRole, }, deps); } const requested = await requestTaskClarification({ taskId, requestedBy: workerId ?? "worker", requestedByWorkerId: workerId, requestedByRole: currentTask.assignedRole, question: effectiveContract.questions[0] ?? "This task is blocked and needs a human decision before work can continue. What should TeamClaw do next?", blockingReason: effectiveContract.blockers[0] ?? effectiveContract.summary, context: [ effectiveContract.notes, result.trim(), effectiveContract.keyPoints.length > 0 ? `Worker-provided commands/details:\n${effectiveContract.keyPoints.join("\n")}` : "", ].filter(Boolean).join("\n\n"), }, deps); if (requested.status === "missing-task") { sendError(res, 404, "Task not found"); return; } if (requested.status === "conflict") { sendError(res, 409, "Cannot request clarification for a completed task"); return; } sendJson(res, requested.status === "already-pending" ? 200 : 201, { status: requested.status, clarification: requested.clarification, task: serializeTask(requested.task), }); return; } const gatedTask = getTeamState()?.tasks[taskId]; if ( !error && gatedTask && taskRequiresMeaningfulProjectChangeGate(gatedTask) && !projectHasMeaningfulFileChanges(gatedTask, effectiveContract) && !allowsNoChangeCompletion(gatedTask, submittedContract, result) ) { error = "Task reported completion but no meaningful project file changes were detected in the assigned project directory."; recordTaskExecutionEvent(taskId, { type: "warning", phase: "completion_gate", source: "controller", status: "failed", message: error, workerId, role: gatedTask.assignedRole, }, deps); } const updatedTask = applyTaskResult(taskId, result, error, deps); ensureTaskResultContract(taskId, result, error, deps); if (!error) { deps.previewManager?.syncTaskPreviews(taskId).catch((err) => { logger.warn(`Controller: failed to sync previews for ${taskId}: ${String(err)}`); }); } if (!workerId || workerId !== previousWorkerId) { await cancelTaskExecution(taskId, previousWorkerId, "manual result submission", deps); } sendJson(res, 200, { task: serializeTask(getTeamState()?.tasks[taskId] ?? updatedTask) }); return; } // POST /api/v1/tasks/:id/execution if (req.method === "POST" && pathname.match(/^\/api\/v1\/tasks\/[^/]+\/execution$/)) { const taskId = pathname.split("/")[4]!; const body = await parseJsonBody(req); const type = typeof body.type === "string" ? body.type : ""; const message = typeof body.message === "string" ? body.message : ""; const workerId = typeof body.workerId === "string" ? body.workerId : undefined; const currentTask = getTeamState()?.tasks[taskId]; if (!type || !message) { sendError(res, 400, "type and message are required"); return; } if (!currentTask) { sendError(res, 404, "Task not found"); return; } if (workerId && !canAcceptWorkerUpdate(currentTask, workerId)) { logger.info(`Controller: ignoring stale execution event for ${taskId} from ${workerId}`); sendJson(res, 202, { status: "ignored", reason: "stale-worker-event" }); return; } const recorded = recordTaskExecutionEvent(taskId, { type: type as TaskExecutionEventInput["type"], message, createdAt: typeof body.createdAt === "number" ? body.createdAt : undefined, phase: typeof body.phase === "string" ? body.phase : undefined, source: typeof body.source === "string" ? body.source as TaskExecutionEventInput["source"] : undefined, stream: typeof body.stream === "string" ? body.stream : undefined, role: typeof body.role === "string" ? body.role as RoleId : undefined, workerId, runId: typeof body.runId === "string" ? body.runId : undefined, sessionKey: typeof body.sessionKey === "string" ? body.sessionKey : undefined, status: typeof body.status === "string" ? body.status as TaskExecutionEventInput["status"] : undefined, }, deps); if (!recorded.task || !recorded.event) { sendError(res, 404, "Task not found"); return; } // When a result contract is backfilled, the task is effectively complete — // trigger preview sync so that any web-app deliverables get previewed. if (recorded.event?.phase === "result_contract_backfilled") { deps.previewManager?.syncTaskPreviews(taskId).catch((err) => { logger.warn(`Controller: failed to sync previews for ${taskId}: ${String(err)}`); }); } sendJson(res, 201, { task: serializeTask(recorded.task), execution: buildTaskExecutionSummary(recorded.task.execution), event: recorded.event, }); return; } // POST /api/v1/tasks/:id/parallel-help if (req.method === "POST" && pathname.match(/^\/api\/v1\/tasks\/[^/]+\/parallel-help$/)) { const taskId = pathname.split("/")[4]!; const body = await parseJsonBody(req); const currentTask = getTeamState()?.tasks[taskId]; if (!currentTask) { sendError(res, 404, "Task not found"); return; } const requestedBy = typeof body.requestedBy === "string" ? body.requestedBy : ""; const reason = typeof body.reason === "string" ? body.reason.trim() : ""; const requestedByRole = typeof body.requestedByRole === "string" ? body.requestedByRole as RoleId : undefined; const targetRole = typeof body.targetRole === "string" ? body.targetRole as RoleId : undefined; const requestedWorkerCount = typeof body.requestedWorkerCount === "number" ? Math.max(2, Math.min(10, Math.floor(body.requestedWorkerCount))) : undefined; const suggestedWorkstreams = Array.isArray(body.suggestedWorkstreams) ? body.suggestedWorkstreams.map((entry: unknown) => String(entry ?? "").trim()).filter(Boolean) : []; if (!requestedBy || !reason) { sendError(res, 400, "requestedBy and reason are required"); return; } const sessionKey = resolveControllerWorkflowSessionKey(currentTask, getTeamState()); if (!sessionKey) { sendError(res, 409, "Task is not linked to a controller session"); return; } recordTaskExecutionEvent(taskId, { type: "progress", phase: "parallel_help_requested", source: "worker", message: `Worker requested more ${targetRole || requestedByRole || currentTask.assignedRole || "developer"} capacity for parallel work: ${reason}`, workerId: requestedBy, role: requestedByRole, }, deps); const result = await runControllerIntake( buildControllerParallelHelpMessage(currentTask, getTeamState(), { requestedBy, requestedByRole, targetRole, reason, requestedWorkerCount, suggestedWorkstreams, }), sessionKey, deps, { source: "task_follow_up", sourceTaskId: currentTask.id, sourceTaskTitle: currentTask.title, }, ); sendJson(res, 201, result); return; } // ==================== Message Routing ==================== // POST /api/v1/controller/manifest if (req.method === "POST" && pathname === "/api/v1/controller/manifest") { const body = await parseJsonBody(req); const sessionKey = normalizeControllerIntakeSessionKey(body.sessionKey); const manifest = normalizeControllerManifest(body.manifest); if (!manifest) { sendError(res, 400, "manifest is required and must include requirementSummary"); return; } const runId = findLatestControllerRunIdForSession(sessionKey, deps.getTeamState(), { preferActive: true, }); if (!runId) { sendError(res, 404, "Controller run not found for session"); return; } const updatedRun = updateControllerRun(runId, deps, (run) => { run.manifest = manifest; const state = deps.getTeamState(); if (state) { const project = syncProjectRegistryEntry(state, { projectId: manifest.projectName ?? run.projectId, projectDir: run.projectDir ?? manifest.projectName, aliases: [manifest.projectName, run.projectId], summary: manifest.requirementSummary, updatedAt: Date.now(), }); run.projectId = project.projectId; run.projectDir = project.projectDir; } if (run.projectDir) { const state = deps.getTeamState(); if (state) { for (const taskId of run.createdTaskIds) { const task = state.tasks[taskId]; if (task) { if (!task.projectDir) { task.projectDir = run.projectDir; } if (!task.projectId) { task.projectId = run.projectId; } } } } } appendControllerRunEvent(run, { type: "output", phase: "manifest_recorded", source: "controller", status: "running", sessionKey, message: buildControllerManifestEventMessage(manifest), }); }); if (!updatedRun) { sendError(res, 404, "Controller run not found"); return; } if (manifest.requirementFullyComplete) { logger.info(`Controller: requirement fully complete for session ${sessionKey} (run ${runId})`); wsServer.broadcastUpdate({ type: "requirement:complete", data: { runId, sessionKey, requirementSummary: manifest.requirementSummary, }, }); } sendJson(res, 201, { controllerRun: serializeControllerRun(updatedRun), manifest, }); return; } // GET /api/v1/controller/runs if (req.method === "GET" && pathname === "/api/v1/controller/runs") { const state = reconcileControllerClarifications(deps); const controllerRuns = state ? Object.values(state.controllerRuns) .sort((left, right) => right.updatedAt - left.updatedAt) .map((run) => serializeControllerRun(run)) : []; sendJson(res, 200, { controllerRuns }); return; } // POST /api/v1/controller/intake if (req.method === "POST" && pathname === "/api/v1/controller/intake") { const readinessGate = shouldBlockControllerUntilProvisioningReady(deps, currentState); if (readinessGate.blocked && readinessGate.readiness) { sendError(res, 503, buildStartupReadinessMessage(readinessGate.readiness)); return; } const body = await parseJsonBody(req); const message = typeof body.message === "string" ? body.message.trim() : ""; if (!message) { sendError(res, 400, "message is required"); return; } const sessionKey = normalizeControllerIntakeSessionKey(body.sessionKey); try { const result = await runControllerIntake(message, sessionKey, deps); sendJson(res, 200, result); } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); logger.warn(`Controller: intake failed for ${sessionKey}: ${errorMessage}`); sendError(res, errorMessage.includes("timed out") ? 504 : 500, errorMessage); } return; } // POST /api/v1/messages/direct if (req.method === "POST" && pathname === "/api/v1/messages/direct") { const body = await parseJsonBody(req); const message: TeamMessage = { id: generateId(), from: typeof body.from === "string" ? body.from : "", fromRole: typeof body.fromRole === "string" ? body.fromRole as RoleId : undefined, toRole: typeof body.toRole === "string" ? body.toRole as RoleId : undefined, type: "direct", content: typeof body.content === "string" ? body.content : "", contract: ensureTeamMessageContract(body.contract, { type: "direct", content: typeof body.content === "string" ? body.content : "", toRole: typeof body.toRole === "string" ? body.toRole as RoleId : undefined, taskId: typeof body.taskId === "string" ? body.taskId : undefined, }), taskId: typeof body.taskId === "string" ? body.taskId : undefined, createdAt: Date.now(), }; updateTeamState((s) => { s.messages.push(message); }); const routed = await routeDirectMessage(message, deps); wsServer.broadcastUpdate({ type: "message:new", data: message }); sendJson(res, 201, { status: routed ? "delivered" : "no-target", message }); return; } // POST /api/v1/messages/broadcast if (req.method === "POST" && pathname === "/api/v1/messages/broadcast") { const body = await parseJsonBody(req); const message: TeamMessage = { id: generateId(), from: typeof body.from === "string" ? body.from : "", fromRole: typeof body.fromRole === "string" ? body.fromRole as RoleId : undefined, type: "broadcast", content: typeof body.content === "string" ? body.content : "", contract: ensureTeamMessageContract(body.contract, { type: "broadcast", content: typeof body.content === "string" ? body.content : "", taskId: typeof body.taskId === "string" ? body.taskId : undefined, }), taskId: typeof body.taskId === "string" ? body.taskId : undefined, createdAt: Date.now(), }; updateTeamState((s) => { s.messages.push(message); }); const state = getTeamState()!; const routed = messageRouter.routeBroadcast(message, state.workers); for (const { worker, message: routedMsg } of routed) { try { await deliverMessageToWorker(worker, routedMsg, deps); } catch (err) { logger.warn(`Controller: failed to broadcast to ${worker.id}: ${String(err)}`); } } wsServer.broadcastUpdate({ type: "message:new", data: message }); sendJson(res, 201, { status: "broadcast", recipients: routed.length }); return; } // POST /api/v1/messages/review-request if (req.method === "POST" && pathname === "/api/v1/messages/review-request") { const body = await parseJsonBody(req); const message: TeamMessage = { id: generateId(), from: typeof body.from === "string" ? body.from : "", fromRole: typeof body.fromRole === "string" ? body.fromRole as RoleId : undefined, toRole: typeof body.toRole === "string" ? body.toRole as RoleId : undefined, type: "review-request", content: typeof body.content === "string" ? body.content : "", contract: ensureTeamMessageContract(body.contract, { type: "review-request", content: typeof body.content === "string" ? body.content : "", toRole: typeof body.toRole === "string" ? body.toRole as RoleId : undefined, taskId: typeof body.taskId === "string" ? body.taskId : undefined, intent: "review-request", needsResponse: true, }), taskId: typeof body.taskId === "string" ? body.taskId : undefined, createdAt: Date.now(), }; updateTeamState((s) => { s.messages.push(message); }); const state = getTeamState()!; const routed = messageRouter.routeReviewRequest(message, state.workers); if (routed) { try { await deliverMessageToWorker(routed.worker, routed.message, deps); } catch (err) { logger.warn(`Controller: failed to deliver review request: ${String(err)}`); } } wsServer.broadcastUpdate({ type: "message:new", data: message }); sendJson(res, 201, { status: routed ? "delivered" : "no-target", message }); return; } // GET /api/v1/messages if (req.method === "GET" && pathname === "/api/v1/messages") { const state = getTeamState(); const messages = state?.messages ?? []; const limit = parseInt(requestUrl.searchParams.get("limit") ?? "50", 10); const offset = parseInt(requestUrl.searchParams.get("offset") ?? "0", 10); sendJson(res, 200, { messages: messages.slice(offset, offset + limit), total: messages.length, }); return; } // ==================== Clarification Requests ==================== // POST /api/v1/clarifications if (req.method === "POST" && pathname === "/api/v1/clarifications") { const body = await parseJsonBody(req); const taskId = typeof body.taskId === "string" ? body.taskId : ""; const requestedBy = typeof body.requestedBy === "string" ? body.requestedBy : ""; const requestedByWorkerId = typeof body.requestedByWorkerId === "string" ? body.requestedByWorkerId : undefined; const requestedByRole = typeof body.requestedByRole === "string" ? body.requestedByRole as RoleId : undefined; const question = typeof body.question === "string" ? body.question.trim() : ""; const blockingReason = typeof body.blockingReason === "string" ? body.blockingReason.trim() : ""; const context = typeof body.context === "string" && body.context.trim() ? body.context.trim() : undefined; const questionSchema = normalizeClarificationQuestionSchema(body.questionSchema); if (!taskId || !question || !blockingReason) { sendError(res, 400, "taskId, question, and blockingReason are required"); return; } const requested = await requestTaskClarification({ taskId, requestedBy, requestedByWorkerId, requestedByRole, question, questionSchema, blockingReason, context, }, deps); if (requested.status === "missing-task") { sendError(res, 404, "Task not found"); return; } if (requested.status === "conflict") { sendError(res, 409, "Cannot request clarification for a completed task"); return; } if (requested.status === "already-pending") { sendJson(res, 200, { clarification: requested.clarification, task: serializeTask(requested.task), status: "already-pending", }); return; } sendJson(res, 201, { clarification: requested.clarification, task: serializeTask(requested.task), }); return; } // GET /api/v1/clarifications if (req.method === "GET" && pathname === "/api/v1/clarifications") { const state = reconcileControllerClarifications(deps); const clarifications = state ? Object.values(state.clarifications).sort((left, right) => right.createdAt - left.createdAt) : []; sendJson(res, 200, { clarifications, pendingCount: clarifications.filter((item) => item.status === "pending").length, }); return; } // POST /api/v1/clarifications/:id/answer if (req.method === "POST" && pathname.match(/^\/api\/v1\/clarifications\/[^/]+\/answer$/)) { const clarificationId = pathname.split("/")[4]!; const body = await parseJsonBody(req); const answerValue = typeof body.answerValue === "string" ? body.answerValue.trim() : undefined; const answerValues = Array.isArray(body.answerValues) ? body.answerValues.map((entry) => String(entry || "").trim()).filter(Boolean) : undefined; const answerNumber = typeof body.answerNumber === "number" && Number.isFinite(body.answerNumber) ? body.answerNumber : undefined; const answerComment = typeof body.answerComment === "string" && body.answerComment.trim() ? body.answerComment.trim() : undefined; const answeredBy = typeof body.answeredBy === "string" && body.answeredBy.trim() ? body.answeredBy.trim() : "human"; const currentState = getTeamState(); const currentClarification = currentState?.clarifications[clarificationId]; if (!currentClarification) { sendError(res, 404, "Clarification request not found"); return; } const answer = buildStructuredClarificationAnswer(currentClarification, { answer: typeof body.answer === "string" ? body.answer.trim() : "", answerValue, answerValues, answerNumber, answerComment, }); if (!answer) { sendError(res, 400, "answer is required"); return; } if (currentClarification.status === "answered") { sendError(res, 409, "Clarification request already answered"); return; } const now = Date.now(); const state = updateTeamState((s) => { const clarification = s.clarifications[clarificationId]; if (!clarification) { return; } clarification.status = "answered"; clarification.answer = answer; clarification.answerValue = answerValue; clarification.answerValues = answerValues; clarification.answerNumber = answerNumber; clarification.answerComment = answerComment; clarification.answeredBy = answeredBy; clarification.answeredAt = now; clarification.updatedAt = now; const task = s.tasks[clarification.taskId]; if (!task) { return; } task.status = "pending"; task.progress = `Clarification answered by ${answeredBy}: ${answer}`; task.clarificationRequestId = undefined; resetTaskForFreshAttempt(task); task.updatedAt = now; }); const clarification = state.clarifications[clarificationId]; const task = clarification?.taskId ? state.tasks[clarification.taskId] : undefined; let responseMessage: TeamMessage | undefined; if (clarification?.requestedByRole && task) { responseMessage = { id: generateId(), from: answeredBy, toRole: clarification.requestedByRole, type: "direct", content: `Clarification answer for task ${task.id}: ${answer}`, contract: ensureTeamMessageContract(null, { type: "direct", content: `Clarification answer for task ${task.id}: ${answer}`, toRole: clarification.requestedByRole, taskId: task.id, summary: `Clarification answered for task ${task.id}`, details: answer, requestedAction: "Resume the task using this clarification.", needsResponse: false, intent: "update", }), taskId: task.id, createdAt: now, }; updateTeamState((s) => { s.messages.push(responseMessage!); }); await routeDirectMessage(responseMessage, deps); wsServer.broadcastUpdate({ type: "message:new", data: responseMessage }); } let resumedTask = task; let resumedWorker: WorkerInfo | null = null; let continuedControllerRun: { sessionKey: string; controllerRunId?: string; runId?: string; reply?: string; queued?: boolean; } | null = null; if (task) { const latestState = getTeamState()!; if (clarification?.requestedByWorkerId && latestState.workers[clarification.requestedByWorkerId]?.status === "idle") { resumedWorker = latestState.workers[clarification.requestedByWorkerId]!; } else { resumedWorker = taskRouter.routeTask(task, latestState.workers); } if (resumedWorker) { resumedTask = await assignTaskToWorker(task.id, resumedWorker, deps, { assignedRole: task.assignedRole, }); } } else if (clarification?.controllerRunId) { const targetSessionKey = clarification.controllerSessionKey || state.controllerRuns[clarification.controllerRunId]?.sessionKey; if (targetSessionKey) { continuedControllerRun = { sessionKey: targetSessionKey, controllerRunId: clarification.controllerRunId, queued: true, }; void runControllerIntake( buildControllerClarificationAnswerMessage(clarification, answer, answeredBy), targetSessionKey, deps, ).catch((err) => { deps.logger.warn( `Controller: failed to continue intake after clarification ${clarification.id}: ${String(err)}`, ); }); } } wsServer.broadcastUpdate({ type: "clarification:answered", data: clarification }); if (resumedTask) { recordTaskExecutionEvent(resumedTask.id, { type: "lifecycle", phase: "clarification_answered", source: "controller", message: `Clarification answered by ${answeredBy}: ${answer}`, role: clarification?.requestedByRole, workerId: clarification?.requestedByWorkerId, }, deps); wsServer.broadcastUpdate({ type: "task:updated", data: serializeTask(resumedTask) }); } sendJson(res, 200, { clarification, task: serializeTask(resumedTask), resumedWorker, controllerRun: continuedControllerRun, message: responseMessage, }); return; } // ==================== Git Collaboration ==================== // GET /api/v1/repo if (req.method === "GET" && pathname === "/api/v1/repo") { const repo = await refreshControllerRepoState(deps); if (!repo?.enabled) { sendJson(res, 200, { enabled: false }); return; } sendJson(res, 200, { repo }); return; } // GET /api/v1/repo/bundle if (req.method === "GET" && pathname === "/api/v1/repo/bundle") { try { const exported = await exportControllerGitBundle(config, logger); res.writeHead(200, { "Content-Type": "application/octet-stream", "Content-Length": exported.data.byteLength, "Content-Disposition": `attachment; filename="${exported.filename}"`, "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE, OPTIONS", "Access-Control-Allow-Headers": "Content-Type", }); res.end(exported.data); } catch (err) { sendError(res, 503, err instanceof Error ? err.message : String(err)); } return; } // POST /api/v1/repo/import if (req.method === "POST" && pathname === "/api/v1/repo/import") { const body = await readRequestBody(req); if (!body.length) { sendError(res, 400, "bundle body is required"); return; } const taskId = typeof requestUrl.searchParams.get("taskId") === "string" && requestUrl.searchParams.get("taskId") ? requestUrl.searchParams.get("taskId")! : undefined; const workerId = typeof requestUrl.searchParams.get("workerId") === "string" && requestUrl.searchParams.get("workerId") ? requestUrl.searchParams.get("workerId")! : undefined; const role = typeof requestUrl.searchParams.get("role") === "string" && requestUrl.searchParams.get("role") ? requestUrl.searchParams.get("role") as RoleId : undefined; try { const imported = await importControllerGitBundle(config, logger, body, { taskId, workerId }); updateTeamState((s) => { s.repo = imported.repo; }); if (taskId) { recordTaskExecutionEvent(taskId, { type: imported.merged || imported.alreadyUpToDate ? "lifecycle" : "error", phase: imported.merged ? "repo_imported" : imported.alreadyUpToDate ? "repo_import_skipped" : "repo_import_failed", source: "controller", message: imported.message, workerId, role, }, deps); } sendJson(res, imported.merged || imported.alreadyUpToDate ? 200 : 409, { repo: imported.repo, merged: imported.merged, fastForwarded: imported.fastForwarded, alreadyUpToDate: imported.alreadyUpToDate, message: imported.message, }); } catch (err) { const message = err instanceof Error ? err.message : String(err); if (taskId) { recordTaskExecutionEvent(taskId, { type: "error", phase: "repo_import_failed", source: "controller", message, workerId, role, }, deps); } sendError(res, 500, message); } return; } // ==================== Team Info ==================== // GET /api/v1/team/status if (req.method === "GET" && pathname === "/api/v1/team/status") { const state = reconcileControllerClarifications(deps); const startupReadiness = getEffectiveStartupReadiness(deps, state); const modelReadiness = getTeamClawModelReadiness(); const externalWorkerInstall = buildExternalWorkerInstallInfo(req, config); if (!state) { sendJson(res, 200, { teamName: config.teamName, workers: [], tasks: [], controllerRuns: [], messages: [], clarifications: [], previews: [], repo: null, pendingClarificationCount: 0, modelReadiness, externalWorkerInstall, provisioning: startupReadiness ? { startupReadiness } : undefined, }); return; } const clarifications = Object.values(state.clarifications).sort((left, right) => right.createdAt - left.createdAt); sendJson(res, 200, { teamName: state.teamName, workers: Object.values(state.workers), tasks: Object.values(state.tasks).map((task) => serializeTask(task)), controllerRuns: Object.values(state.controllerRuns) .sort((left, right) => right.updatedAt - left.updatedAt) .map((run) => serializeControllerRun(run)), messages: state.messages, clarifications, previews: Object.values(state.previews ?? {}), repo: state.repo ?? null, modelReadiness, externalWorkerInstall, provisioning: { ...(state.provisioning ?? { workers: {} }), startupReadiness, }, taskCount: Object.keys(state.tasks).length, workerCount: Object.keys(state.workers).length, pendingClarificationCount: clarifications.filter((item) => item.status === "pending").length, }); return; } // GET /api/v1/roles if (req.method === "GET" && pathname === "/api/v1/roles") { sendJson(res, 200, { roles: ROLES }); return; } // /api/v1/previews/:id/* — proxy to preview subprocess if (req.method === "GET" || req.method === "HEAD" || req.method === "POST" || req.method === "PUT" || req.method === "DELETE") { const previewPrefix = "/api/v1/previews/"; if (pathname.startsWith(previewPrefix)) { const remaining = pathname.slice(previewPrefix.length); const slashIdx = remaining.indexOf("/"); const previewId = slashIdx >= 0 ? remaining.slice(0, slashIdx) : remaining; const subPath = slashIdx >= 0 ? remaining.slice(slashIdx) : "/"; const state = deps.getTeamState(); const preview = state?.previews?.[previewId]; if (!preview || preview.status !== "healthy") { const status = preview?.status ?? "unknown"; const errMsg = preview?.lastError; sendJson(res, 503, { error: "Preview not available", previewId, status, lastError: errMsg ?? undefined }); return; } const proxyPathPrefix = `/api/v1/previews/${encodeURIComponent(previewId)}`; const proxyReq = http.request( { hostname: "127.0.0.1", port: preview.targetPort, path: subPath + (requestUrl.search || ""), method: req.method, headers: { ...req.headers, host: `127.0.0.1:${preview.targetPort}`, "accept-encoding": "identity", "x-forwarded-for": req.socket.remoteAddress ?? "", "x-forwarded-host": req.headers.host ?? "", "x-forwarded-proto": "http", }, }, (proxyRes: http.IncomingMessage) => { const contentType = (proxyRes.headers["content-type"] ?? "").toLowerCase(); if (proxyRes.statusCode !== 200 && proxyRes.statusCode !== 201 && proxyRes.statusCode !== 301 && proxyRes.statusCode !== 302 && proxyRes.statusCode !== 304) { res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers); proxyRes.pipe(res); return; } if (contentType.includes("text/html")) { const chunks: Buffer[] = []; proxyRes.on("data", (chunk: Buffer) => chunks.push(chunk)); proxyRes.on("end", () => { let raw = Buffer.concat(chunks); // Decompress if the upstream response is compressed (brotli/gzip/deflate). const encoding = (proxyRes.headers["content-encoding"] ?? "").toLowerCase(); try { if (encoding === "br") { raw = zlib.brotliDecompressSync(raw); } else if (encoding === "gzip") { raw = zlib.gunzipSync(raw); } else if (encoding === "deflate") { raw = zlib.inflateSync(raw); } } catch { // If decompression fails, use raw bytes as-is. } const body = raw.toString("utf-8"); // Rewrite absolute-path references (href="/...", src="/...", action="/...") // to include the proxy prefix so all navigation stays within the proxy. const rewritten = body.replace( /\b(href|src|action)\s*=\s*"\/([^"]*)"/g, `$1="${proxyPathPrefix}/$2"`, ).replace( /\b(href|src|action)\s*=\s*'\/([^']*)'/g, `$1='${proxyPathPrefix}/$2'`, ); const resHeaders = { ...proxyRes.headers }; // We've decoded and rewritten the body — remove upstream encoding headers. delete resHeaders["content-encoding"]; delete resHeaders["transfer-encoding"]; resHeaders["content-length"] = String(Buffer.byteLength(rewritten, "utf-8")); res.writeHead(proxyRes.statusCode ?? 200, resHeaders); res.end(rewritten); }); } else { res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers); proxyRes.pipe(res); } }, ); proxyReq.on("error", (err: Error) => { deps.logger.warn(`Controller: preview proxy error for ${previewId}: ${String(err)}`); if (!res.headersSent) { sendJson(res, 502, { error: "Preview proxy error", previewId, message: String(err) }); } }); req.pipe(proxyReq); return; } } // GET /api/v1/reports — list all delivery reports if (req.method === "GET" && pathname === "/api/v1/reports") { const state = deps.getTeamState(); const reports = Object.values(state?.reports ?? {}).sort((a, b) => b.generatedAt - a.generatedAt); sendJson(res, 200, { reports }); return; } // GET /api/v1/reports/:sessionKey — serve delivery report page if (req.method === "GET" && pathname.startsWith("/api/v1/reports/")) { const sessionKey = decodeURIComponent(pathname.slice("/api/v1/reports/".length)); const state = deps.getTeamState(); if (!state) { sendError(res, 503, "Team state not loaded"); return; } const report = generateDeliveryReport(sessionKey, state, normalizeControllerIntakeSessionKey); if (!report) { sendError(res, 404, "No report found for this session"); return; } const accept = (req.headers["accept"] ?? "").toLowerCase(); if (accept.includes("application/json")) { sendJson(res, 200, { report }); } else { const html = renderReportHtml(report); res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Content-Length": Buffer.byteLength(html, "utf-8") }); res.end(html); } return; } // GET /api/v1/health if (req.method === "GET" && pathname === "/api/v1/health") { const readiness = getEffectiveStartupReadiness(deps, currentState); const modelReadiness = getTeamClawModelReadiness(); const statusCode = readiness && readiness.status !== "ready" ? 503 : 200; sendJson(res, statusCode, { status: readiness?.status === "degraded" ? "degraded" : readiness?.status === "checking" ? "starting" : "ok", mode: "controller", timestamp: Date.now(), provisioningReadiness: readiness, modelReadiness, }); return; } sendError(res, 404, "Not found"); }