@teamclaws/teamclaw 2026.3.26-2 → 2026.4.2-2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -8
- package/cli.mjs +538 -224
- package/index.ts +76 -27
- package/openclaw.plugin.json +53 -28
- package/package.json +5 -2
- package/skills/teamclaw/SKILL.md +213 -0
- package/skills/teamclaw/references/api-quick-ref.md +117 -0
- package/skills/teamclaw-setup/SKILL.md +81 -0
- package/skills/teamclaw-setup/references/install-modes.md +136 -0
- package/skills/teamclaw-setup/references/validation-checklist.md +73 -0
- package/src/config.ts +44 -16
- package/src/controller/controller-capacity.ts +2 -2
- package/src/controller/controller-service.ts +193 -47
- package/src/controller/controller-tools.ts +102 -2
- package/src/controller/delivery-report.ts +563 -0
- package/src/controller/http-server.ts +1907 -172
- package/src/controller/kickoff-orchestrator.ts +292 -0
- package/src/controller/managed-gateway-process.ts +330 -0
- package/src/controller/orchestration-manifest.ts +69 -1
- package/src/controller/preview-manager.ts +676 -0
- package/src/controller/prompt-injector.ts +116 -67
- package/src/controller/role-inference.ts +41 -0
- package/src/controller/websocket.ts +3 -1
- package/src/controller/worker-provisioning.ts +429 -74
- package/src/discovery.ts +1 -1
- package/src/git-collaboration.ts +198 -47
- package/src/identity.ts +12 -2
- package/src/interaction-contracts.ts +179 -3
- package/src/networking.ts +99 -0
- package/src/openclaw-workspace.ts +478 -11
- package/src/prompt-policy.ts +381 -0
- package/src/roles.ts +37 -36
- package/src/state.ts +40 -1
- package/src/task-executor.ts +282 -78
- package/src/types.ts +150 -7
- package/src/ui/app.js +1403 -175
- package/src/ui/assets/teamclaw-app-icon.png +0 -0
- package/src/ui/index.html +122 -40
- package/src/ui/style.css +829 -143
- package/src/worker/http-handler.ts +40 -4
- package/src/worker/prompt-injector.ts +9 -38
- package/src/worker/skill-installer.ts +2 -2
- package/src/worker/tools.ts +31 -5
- package/src/worker/worker-service.ts +49 -8
- package/src/workspace-browser.ts +20 -7
- package/src/controller/local-worker-manager.ts +0 -533
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import http from "node:http";
|
|
4
|
+
import zlib from "node:zlib";
|
|
4
5
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
5
6
|
import type { OpenClawPluginApi, PluginLogger } from "../../api.js";
|
|
6
7
|
import type {
|
|
@@ -12,6 +13,7 @@ import type {
|
|
|
12
13
|
PluginConfig,
|
|
13
14
|
RepoSyncInfo,
|
|
14
15
|
RoleId,
|
|
16
|
+
StartupProvisioningReadiness,
|
|
15
17
|
TaskExecution,
|
|
16
18
|
TaskExecutionEvent,
|
|
17
19
|
TaskExecutionEventInput,
|
|
@@ -25,6 +27,7 @@ import type {
|
|
|
25
27
|
WorkerProgressContract,
|
|
26
28
|
WorkerInfo,
|
|
27
29
|
WorkerTaskResultContract,
|
|
30
|
+
WorkerTaskResultDeliverable,
|
|
28
31
|
} from "../types.js";
|
|
29
32
|
import {
|
|
30
33
|
parseJsonBody,
|
|
@@ -33,16 +36,17 @@ import {
|
|
|
33
36
|
sendError,
|
|
34
37
|
generateId,
|
|
35
38
|
} from "../protocol.js";
|
|
36
|
-
import { listWorkspaceTree, readWorkspaceFile, readWorkspaceRawFile } from "../workspace-browser.js";
|
|
39
|
+
import { listWorkspaceTree, listWorkspaceSubtree, readWorkspaceFile, readWorkspaceRawFile } from "../workspace-browser.js";
|
|
37
40
|
import { ROLES, normalizeRecommendedSkills, resolveRecommendedSkillsForRole } from "../roles.js";
|
|
38
41
|
import { buildRepoSyncInfo, ensureControllerGitRepo, exportControllerGitBundle, importControllerGitBundle } from "../git-collaboration.js";
|
|
39
|
-
import type { LocalWorkerManager } from "./local-worker-manager.js";
|
|
40
42
|
import { TaskRouter } from "./task-router.js";
|
|
41
43
|
import { MessageRouter } from "./message-router.js";
|
|
42
44
|
import { TeamWebSocketServer } from "./websocket.js";
|
|
43
45
|
import type { WorkerProvisioningManager } from "./worker-provisioning.js";
|
|
46
|
+
import type { PreviewManager } from "./preview-manager.js";
|
|
44
47
|
import { createControllerPromptInjector } from "./prompt-injector.js";
|
|
45
48
|
import { buildControllerNoWorkersMessage, shouldBlockControllerWithoutWorkers } from "./controller-capacity.js";
|
|
49
|
+
import { generateDeliveryReport, isSessionComplete, renderReportHtml, type DeliveryReport } from "./delivery-report.js";
|
|
46
50
|
import {
|
|
47
51
|
backfillWorkerProgressContract,
|
|
48
52
|
backfillWorkerTaskResultContract,
|
|
@@ -50,8 +54,20 @@ import {
|
|
|
50
54
|
normalizeTaskHandoffContract,
|
|
51
55
|
normalizeWorkerProgressContract,
|
|
52
56
|
normalizeWorkerTaskResultContract,
|
|
57
|
+
enrichDeliverablesWithPreviewInference,
|
|
53
58
|
} from "../interaction-contracts.js";
|
|
54
|
-
import {
|
|
59
|
+
import {
|
|
60
|
+
buildTeamClawProjectWorkspacePath,
|
|
61
|
+
buildTeamClawAgentSessionKey,
|
|
62
|
+
getTeamClawModelReadiness,
|
|
63
|
+
resolveTeamClawAgentWorkspaceRootDir,
|
|
64
|
+
resolveTeamClawWorkspaceDir,
|
|
65
|
+
resolveTeamClawProjectsDir,
|
|
66
|
+
deriveProjectSlug,
|
|
67
|
+
} from "../openclaw-workspace.js";
|
|
68
|
+
import { resolvePreferredLanAddress } from "../networking.js";
|
|
69
|
+
import { normalizeClarificationQuestionSchema, normalizeControllerManifest } from "./orchestration-manifest.js";
|
|
70
|
+
import type { KickoffHandler } from "./controller-service.js";
|
|
55
71
|
|
|
56
72
|
export type ControllerHttpDeps = {
|
|
57
73
|
config: PluginConfig;
|
|
@@ -62,8 +78,10 @@ export type ControllerHttpDeps = {
|
|
|
62
78
|
taskRouter: TaskRouter;
|
|
63
79
|
messageRouter: MessageRouter;
|
|
64
80
|
wsServer: TeamWebSocketServer;
|
|
65
|
-
localWorkerManager?: LocalWorkerManager;
|
|
66
81
|
workerProvisioningManager?: WorkerProvisioningManager | null;
|
|
82
|
+
previewManager?: PreviewManager;
|
|
83
|
+
/** Late-bound kickoff handler for automatic team kickoff on complex projects. */
|
|
84
|
+
getKickoffHandler?: () => KickoffHandler | undefined;
|
|
67
85
|
};
|
|
68
86
|
|
|
69
87
|
const MAX_TASK_EXECUTION_EVENTS = 250;
|
|
@@ -73,10 +91,44 @@ const MAX_TASK_CONTEXT_SUMMARY_CHARS = 500;
|
|
|
73
91
|
const CONTROLLER_INTAKE_SESSION_PREFIX = "teamclaw-controller-web:";
|
|
74
92
|
const CONTROLLER_INTAKE_AGENT_SESSION_RE = /^agent:[^:]+:(teamclaw-controller-web:[a-zA-Z0-9:_-]{1,120})$/;
|
|
75
93
|
const CONTROLLER_RUN_WAIT_SLICE_MS = 30_000;
|
|
94
|
+
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;
|
|
95
|
+
const PROJECT_MATCH_STOPWORDS = new Set([
|
|
96
|
+
"a",
|
|
97
|
+
"an",
|
|
98
|
+
"and",
|
|
99
|
+
"app",
|
|
100
|
+
"application",
|
|
101
|
+
"build",
|
|
102
|
+
"complete",
|
|
103
|
+
"existing",
|
|
104
|
+
"follow",
|
|
105
|
+
"for",
|
|
106
|
+
"hub",
|
|
107
|
+
"improvement",
|
|
108
|
+
"improvements",
|
|
109
|
+
"internal",
|
|
110
|
+
"optimization",
|
|
111
|
+
"product",
|
|
112
|
+
"project",
|
|
113
|
+
"requirement",
|
|
114
|
+
"request",
|
|
115
|
+
"studio",
|
|
116
|
+
"system",
|
|
117
|
+
"the",
|
|
118
|
+
"this",
|
|
119
|
+
"up",
|
|
120
|
+
"update",
|
|
121
|
+
"web",
|
|
122
|
+
]);
|
|
76
123
|
const CONTROLLER_RATE_LIMIT_STALL_PROBE_MS = 5 * 60 * 1000;
|
|
77
124
|
const CONTROLLER_RATE_LIMIT_PROBE_TIMEOUT_MS = 60_000;
|
|
125
|
+
const CONTROLLER_INACTIVITY_PROBE_TIMEOUT_MS = 60_000;
|
|
78
126
|
const CONTROLLER_RATE_LIMIT_WAITING_SENTINEL = "TEAMCLAW_STILL_WAITING";
|
|
127
|
+
const CONTROLLER_INTAKE_MAX_RETRIES = 2;
|
|
128
|
+
const CONTROLLER_INTAKE_RETRY_DELAY_MS = 3_000;
|
|
129
|
+
const CONTROLLER_INTAKE_RETRYABLE_ERROR_PATTERN = /(500|502|503|server error|internal error|overloaded|unavailable)/i;
|
|
79
130
|
const controllerIntakeQueue = new Map<string, Promise<void>>();
|
|
131
|
+
const EXTERNAL_WORKER_INSTALL_SPEC = "@teamclaws/teamclaw";
|
|
80
132
|
|
|
81
133
|
export function buildControllerIntakeSystemPrompt(
|
|
82
134
|
deps: Pick<ControllerHttpDeps, "config" | "getTeamState">,
|
|
@@ -88,6 +140,49 @@ export function buildControllerIntakeSystemPrompt(
|
|
|
88
140
|
return injector()?.prependSystemContext ?? "";
|
|
89
141
|
}
|
|
90
142
|
|
|
143
|
+
function resolveRequestPort(req: IncomingMessage, fallbackPort: number): number {
|
|
144
|
+
const hostHeader = req.headers.host?.trim();
|
|
145
|
+
if (!hostHeader) {
|
|
146
|
+
return fallbackPort;
|
|
147
|
+
}
|
|
148
|
+
try {
|
|
149
|
+
const parsed = new URL(`http://${hostHeader}`);
|
|
150
|
+
return parsed.port ? Number(parsed.port) : fallbackPort;
|
|
151
|
+
} catch {
|
|
152
|
+
return fallbackPort;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function resolveRecommendedLanControllerUrl(req: IncomingMessage, fallbackPort: number): string {
|
|
157
|
+
const port = resolveRequestPort(req, fallbackPort);
|
|
158
|
+
const preferredLan = resolvePreferredLanAddress();
|
|
159
|
+
return preferredLan ? `http://${preferredLan}:${port}` : "";
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function shellEscapeSingleQuotes(value: string): string {
|
|
163
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function buildExternalWorkerInstallInfo(req: IncomingMessage, config: PluginConfig): Record<string, unknown> {
|
|
167
|
+
const recommendedControllerUrl = resolveRecommendedLanControllerUrl(req, config.port);
|
|
168
|
+
return {
|
|
169
|
+
teamName: config.teamName,
|
|
170
|
+
recommendedControllerUrl,
|
|
171
|
+
roles: ROLES.map((role) => ({ id: role.id, label: role.label, icon: role.icon })),
|
|
172
|
+
autoDiscoveryCommandPrefix:
|
|
173
|
+
`npx -y ${EXTERNAL_WORKER_INSTALL_SPEC} install --yes --install-mode worker --team-name ${shellEscapeSingleQuotes(config.teamName)} --worker-role `,
|
|
174
|
+
manualCommandPrefix:
|
|
175
|
+
`npx -y ${EXTERNAL_WORKER_INSTALL_SPEC} install --yes --install-mode worker --team-name ${shellEscapeSingleQuotes(config.teamName)} --worker-role `,
|
|
176
|
+
manualControllerUrlFlag: recommendedControllerUrl
|
|
177
|
+
? ` --controller-url ${shellEscapeSingleQuotes(recommendedControllerUrl)}`
|
|
178
|
+
: "",
|
|
179
|
+
manualControllerWarning: recommendedControllerUrl
|
|
180
|
+
? "Manual worker installs must use the controller LAN IP, not localhost/127.0.0.1."
|
|
181
|
+
: "No private LAN IPv4 address was detected on this controller host yet, so manual worker install commands are unavailable until a LAN address exists.",
|
|
182
|
+
autoDiscoveryWarning: "mDNS auto-discovery only works when the worker and controller are reachable on the same LAN.",
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
91
186
|
function mapTaskStatusToExecutionStatus(taskStatus: TaskStatus, current?: TaskExecution["status"]): TaskExecution["status"] {
|
|
92
187
|
switch (taskStatus) {
|
|
93
188
|
case "completed":
|
|
@@ -152,7 +247,7 @@ function buildTaskExecutionIdentity(taskId: string, workerId: string): {
|
|
|
152
247
|
} {
|
|
153
248
|
const attemptId = generateId();
|
|
154
249
|
return {
|
|
155
|
-
executionSessionKey: `teamclaw-task-${taskId}-${attemptId}
|
|
250
|
+
executionSessionKey: buildTeamClawAgentSessionKey(`teamclaw-task-${taskId}-${attemptId}`),
|
|
156
251
|
executionIdempotencyKey: `teamclaw-${taskId}-${workerId}-${attemptId}`,
|
|
157
252
|
};
|
|
158
253
|
}
|
|
@@ -363,10 +458,16 @@ function createControllerRun(
|
|
|
363
458
|
},
|
|
364
459
|
): ControllerRunInfo {
|
|
365
460
|
const now = Date.now();
|
|
461
|
+
const existingState = deps.getTeamState();
|
|
462
|
+
const inheritedProjectDir = options?.sourceTaskId
|
|
463
|
+
? existingState?.tasks[options.sourceTaskId]?.projectDir
|
|
464
|
+
: resolveProjectDirForSession(sessionKey, existingState)
|
|
465
|
+
?? resolveExistingProjectDirFromMessage(message, existingState);
|
|
366
466
|
const run: ControllerRunInfo = {
|
|
367
467
|
id: generateId(),
|
|
368
468
|
title: buildControllerRunTitle(message, options?.source ?? "human", options?.sourceTaskTitle),
|
|
369
469
|
sessionKey,
|
|
470
|
+
projectDir: inheritedProjectDir ?? deriveProjectSlug(message),
|
|
370
471
|
source: options?.source ?? "human",
|
|
371
472
|
sourceTaskId: options?.sourceTaskId,
|
|
372
473
|
sourceTaskTitle: options?.sourceTaskTitle,
|
|
@@ -386,6 +487,154 @@ function createControllerRun(
|
|
|
386
487
|
return createdRun;
|
|
387
488
|
}
|
|
388
489
|
|
|
490
|
+
function resolveExistingProjectDirFromMessage(
|
|
491
|
+
message: string,
|
|
492
|
+
state: TeamState | null,
|
|
493
|
+
): string | undefined {
|
|
494
|
+
if (!EXISTING_PROJECT_REUSE_HINT_RE.test(message)) {
|
|
495
|
+
return undefined;
|
|
496
|
+
}
|
|
497
|
+
const normalizedMessage = normalizeProjectMatchingText(message);
|
|
498
|
+
if (!normalizedMessage) {
|
|
499
|
+
return undefined;
|
|
500
|
+
}
|
|
501
|
+
const explicitAlias = normalizeProjectMatchingText(extractProjectAliasFromText(message) ?? "");
|
|
502
|
+
|
|
503
|
+
let best: { projectDir: string; score: number; updatedAt: number } | null = null;
|
|
504
|
+
const candidates = collectExistingProjectCandidates(state);
|
|
505
|
+
for (const candidate of candidates) {
|
|
506
|
+
const normalizedAliases = Array.from(candidate.aliases)
|
|
507
|
+
.map((alias) => normalizeProjectMatchingText(alias))
|
|
508
|
+
.filter(Boolean)
|
|
509
|
+
.reduce<string[]>((acc, alias) => {
|
|
510
|
+
acc.push(alias);
|
|
511
|
+
return acc;
|
|
512
|
+
}, []);
|
|
513
|
+
const hasExplicitAliasMatch = explicitAlias
|
|
514
|
+
? normalizedAliases.some((alias) => alias.includes(explicitAlias) || explicitAlias.includes(alias))
|
|
515
|
+
: false;
|
|
516
|
+
if (explicitAlias && !hasExplicitAliasMatch) {
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
const aliasScore = normalizedAliases
|
|
520
|
+
.reduce((score, alias) => {
|
|
521
|
+
if (!alias) {
|
|
522
|
+
return score;
|
|
523
|
+
}
|
|
524
|
+
return normalizedMessage.includes(alias)
|
|
525
|
+
? Math.max(score, alias.split(" ").length + 10)
|
|
526
|
+
: score;
|
|
527
|
+
}, 0);
|
|
528
|
+
const tokenScore = scoreProjectTokenOverlap(normalizedMessage, candidate.searchText);
|
|
529
|
+
const totalScore = Math.max(aliasScore, tokenScore);
|
|
530
|
+
if (totalScore < 2) {
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
if (!best || totalScore > best.score || (totalScore === best.score && candidate.updatedAt > best.updatedAt)) {
|
|
534
|
+
best = {
|
|
535
|
+
projectDir: candidate.projectDir,
|
|
536
|
+
score: totalScore,
|
|
537
|
+
updatedAt: candidate.updatedAt,
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
return best?.projectDir;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function collectExistingProjectCandidates(state: TeamState | null): Array<{
|
|
545
|
+
projectDir: string;
|
|
546
|
+
aliases: Set<string>;
|
|
547
|
+
searchText: string;
|
|
548
|
+
updatedAt: number;
|
|
549
|
+
}> {
|
|
550
|
+
const byProjectDir = new Map<string, { aliases: Set<string>; texts: string[]; updatedAt: number }>();
|
|
551
|
+
const addCandidate = (projectDir: string | undefined, text: string | undefined, updatedAt: number, alias?: string | null) => {
|
|
552
|
+
if (!projectDir) {
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
const entry = byProjectDir.get(projectDir) ?? { aliases: new Set<string>(), texts: [], updatedAt: 0 };
|
|
556
|
+
if (text && text.trim()) {
|
|
557
|
+
entry.texts.push(text);
|
|
558
|
+
}
|
|
559
|
+
if (alias && alias.trim()) {
|
|
560
|
+
entry.aliases.add(alias.trim());
|
|
561
|
+
}
|
|
562
|
+
entry.updatedAt = Math.max(entry.updatedAt, updatedAt);
|
|
563
|
+
byProjectDir.set(projectDir, entry);
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
for (const run of Object.values(state?.controllerRuns ?? {})) {
|
|
567
|
+
addCandidate(run.projectDir, run.request, run.updatedAt, extractProjectAliasFromText(run.request));
|
|
568
|
+
addCandidate(run.projectDir, run.title, run.updatedAt, extractProjectAliasFromText(run.title));
|
|
569
|
+
}
|
|
570
|
+
for (const task of Object.values(state?.tasks ?? {})) {
|
|
571
|
+
addCandidate(task.projectDir, task.title, task.updatedAt, extractProjectAliasFromText(task.title));
|
|
572
|
+
addCandidate(task.projectDir, task.description, task.updatedAt, extractProjectAliasFromText(task.description));
|
|
573
|
+
addCandidate(task.projectDir, task.resultContract?.summary, task.updatedAt);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
return Array.from(byProjectDir.entries()).map(([projectDir, entry]) => ({
|
|
577
|
+
projectDir,
|
|
578
|
+
aliases: entry.aliases,
|
|
579
|
+
searchText: entry.texts.join("\n"),
|
|
580
|
+
updatedAt: entry.updatedAt,
|
|
581
|
+
}));
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function extractProjectAliasFromText(text: string | undefined): string | null {
|
|
585
|
+
const firstMeaningfulLine = String(text ?? "")
|
|
586
|
+
.split(/\n+/u)
|
|
587
|
+
.map((line) => line.replace(/^#+\s*/u, "").trim())
|
|
588
|
+
.find(Boolean);
|
|
589
|
+
if (!firstMeaningfulLine) {
|
|
590
|
+
return null;
|
|
591
|
+
}
|
|
592
|
+
const normalized = firstMeaningfulLine
|
|
593
|
+
.replace(/^(product|optimization)\s+requirement[::]\s*/iu, "")
|
|
594
|
+
.replace(/^implement\s+/iu, "")
|
|
595
|
+
.replace(/^design\s+/iu, "")
|
|
596
|
+
.replace(/^enhance\s+/iu, "")
|
|
597
|
+
.replace(/^qa[:\s-]+/iu, "")
|
|
598
|
+
.replace(/\s+(architecture|application|web app|workflows?|improvements?)$/iu, "")
|
|
599
|
+
.trim();
|
|
600
|
+
if (normalized.length < 4 || normalized.length > 80) {
|
|
601
|
+
return null;
|
|
602
|
+
}
|
|
603
|
+
return normalized;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function normalizeProjectMatchingText(text: string): string {
|
|
607
|
+
return text
|
|
608
|
+
.toLowerCase()
|
|
609
|
+
.replace(/[`*_#:[\]()/\\-]+/gu, " ")
|
|
610
|
+
.replace(/\s+/gu, " ")
|
|
611
|
+
.trim();
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function scoreProjectTokenOverlap(message: string, candidateText: string): number {
|
|
615
|
+
const messageTokens = tokenizeProjectMatchingText(message);
|
|
616
|
+
if (messageTokens.size === 0) {
|
|
617
|
+
return 0;
|
|
618
|
+
}
|
|
619
|
+
const candidateTokens = tokenizeProjectMatchingText(candidateText);
|
|
620
|
+
let overlap = 0;
|
|
621
|
+
for (const token of candidateTokens) {
|
|
622
|
+
if (messageTokens.has(token)) {
|
|
623
|
+
overlap += 1;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
return overlap;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function tokenizeProjectMatchingText(text: string): Set<string> {
|
|
630
|
+
return new Set(
|
|
631
|
+
normalizeProjectMatchingText(text)
|
|
632
|
+
.split(" ")
|
|
633
|
+
.map((token) => token.trim())
|
|
634
|
+
.filter((token) => token.length >= 4 && !PROJECT_MATCH_STOPWORDS.has(token)),
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
|
|
389
638
|
function updateControllerRun(
|
|
390
639
|
runId: string,
|
|
391
640
|
deps: ControllerHttpDeps,
|
|
@@ -422,7 +671,6 @@ function serializeTask(task?: TaskInfo, includeExecutionEvents = false): Record<
|
|
|
422
671
|
}
|
|
423
672
|
|
|
424
673
|
const payload: Record<string, unknown> = { ...task };
|
|
425
|
-
delete payload.controllerSessionKey;
|
|
426
674
|
if (!task.execution) {
|
|
427
675
|
return payload;
|
|
428
676
|
}
|
|
@@ -517,9 +765,10 @@ function normalizeControllerIntakeSessionKey(input: unknown): string {
|
|
|
517
765
|
return fallback;
|
|
518
766
|
}
|
|
519
767
|
|
|
520
|
-
|
|
768
|
+
const normalizedLogicalKey = logicalKey.startsWith(CONTROLLER_INTAKE_SESSION_PREFIX)
|
|
521
769
|
? logicalKey
|
|
522
770
|
: `${CONTROLLER_INTAKE_SESSION_PREFIX}${logicalKey}`;
|
|
771
|
+
return buildTeamClawAgentSessionKey(normalizedLogicalKey);
|
|
523
772
|
}
|
|
524
773
|
|
|
525
774
|
async function withSerializedControllerIntake<T>(
|
|
@@ -693,6 +942,14 @@ function findLatestControllerRunIdForSession(
|
|
|
693
942
|
return matchingRuns[0]?.id ?? null;
|
|
694
943
|
}
|
|
695
944
|
|
|
945
|
+
function resolveProjectDirForSession(
|
|
946
|
+
sessionKey: string,
|
|
947
|
+
state: TeamState | null,
|
|
948
|
+
): string | undefined {
|
|
949
|
+
const runId = findLatestControllerRunIdForSession(sessionKey, state, { preferActive: true });
|
|
950
|
+
return runId ? state?.controllerRuns[runId]?.projectDir : undefined;
|
|
951
|
+
}
|
|
952
|
+
|
|
696
953
|
function resolveControllerWorkflowSessionKey(task: TaskInfo, state: TeamState | null): string | undefined {
|
|
697
954
|
if (task.controllerSessionKey) {
|
|
698
955
|
return normalizeControllerIntakeSessionKey(task.controllerSessionKey);
|
|
@@ -727,6 +984,9 @@ function buildControllerManifestEventMessage(manifest: ControllerOrchestrationMa
|
|
|
727
984
|
if (manifest.clarificationsNeeded) {
|
|
728
985
|
parts.push(`clarifications=${manifest.clarificationQuestions.length}`);
|
|
729
986
|
}
|
|
987
|
+
if (manifest.requirementFullyComplete) {
|
|
988
|
+
parts.push("requirementFullyComplete=true");
|
|
989
|
+
}
|
|
730
990
|
return parts.join(" ");
|
|
731
991
|
}
|
|
732
992
|
|
|
@@ -781,6 +1041,16 @@ function buildControllerManifestReply(
|
|
|
781
1041
|
}
|
|
782
1042
|
}
|
|
783
1043
|
|
|
1044
|
+
// ── Team Kickoff Meeting (brief note; full details rendered in UI) ───
|
|
1045
|
+
if (manifest.kickoffPlan?.assessments?.length) {
|
|
1046
|
+
const kp = manifest.kickoffPlan;
|
|
1047
|
+
const needed = kp.assessments.filter((a) => a.needed).length;
|
|
1048
|
+
lines.push(
|
|
1049
|
+
"",
|
|
1050
|
+
`Team Kickoff Meeting: ${kp.assessments.length} roles assessed, ${needed} confirmed needed. See the Kickoff Meeting panel in the dashboard for full discussion details.`,
|
|
1051
|
+
);
|
|
1052
|
+
}
|
|
1053
|
+
|
|
784
1054
|
if (manifest.handoffPlan) {
|
|
785
1055
|
lines.push("", `Handoff plan: ${manifest.handoffPlan}`);
|
|
786
1056
|
}
|
|
@@ -848,6 +1118,15 @@ function buildBackfilledControllerManifest(
|
|
|
848
1118
|
for (const roleId of inferManifestRolesFromText(rawReply)) {
|
|
849
1119
|
inferredRoles.add(roleId);
|
|
850
1120
|
}
|
|
1121
|
+
for (const roleId of inferManifestRolesFromText(request)) {
|
|
1122
|
+
inferredRoles.add(roleId);
|
|
1123
|
+
}
|
|
1124
|
+
// When no roles could be inferred at all (model didn't call the tool and didn't
|
|
1125
|
+
// mention any role names), fall back to "developer" as the most general purpose role
|
|
1126
|
+
// so that the intake run still has usable machine-readable state.
|
|
1127
|
+
if (inferredRoles.size === 0) {
|
|
1128
|
+
inferredRoles.add("developer");
|
|
1129
|
+
}
|
|
851
1130
|
const clarificationQuestions = inferClarificationQuestionsFromReply(rawReply);
|
|
852
1131
|
return {
|
|
853
1132
|
version: "1.0",
|
|
@@ -855,6 +1134,12 @@ function buildBackfilledControllerManifest(
|
|
|
855
1134
|
requiredRoles: Array.from(inferredRoles),
|
|
856
1135
|
clarificationsNeeded: clarificationQuestions.length > 0 && actualCreatedTasks.length === 0,
|
|
857
1136
|
clarificationQuestions,
|
|
1137
|
+
clarificationSchemas: clarificationQuestions.map((question) => ({
|
|
1138
|
+
kind: "text",
|
|
1139
|
+
title: question,
|
|
1140
|
+
required: true,
|
|
1141
|
+
placeholder: "Provide the missing information",
|
|
1142
|
+
})),
|
|
858
1143
|
createdTasks: actualCreatedTasks.map((task) => ({
|
|
859
1144
|
title: task.title,
|
|
860
1145
|
assignedRole: task.assignedRole,
|
|
@@ -868,6 +1153,173 @@ function buildBackfilledControllerManifest(
|
|
|
868
1153
|
};
|
|
869
1154
|
}
|
|
870
1155
|
|
|
1156
|
+
function looksLikeSoftwareRequirement(request: string): boolean {
|
|
1157
|
+
const normalized = request.toLowerCase();
|
|
1158
|
+
return /(api|backend|frontend|fastapi|react|vue|node|python|typescript|javascript|sql|database|service|app|web|mobile|docker|kubernetes|deploy|测试|系统|平台|接口|服务|数据库|应用|前端|后端)/.test(normalized);
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
function buildFallbackControllerTaskTitle(request: string, assignedRole?: RoleId): string {
|
|
1162
|
+
const firstMeaningfulLine = request
|
|
1163
|
+
.split(/\n+/)
|
|
1164
|
+
.map((line) => line.replace(/^#+\s*/, "").trim())
|
|
1165
|
+
.find(Boolean);
|
|
1166
|
+
if (firstMeaningfulLine) {
|
|
1167
|
+
const normalized = firstMeaningfulLine.replace(/^需求[::]?\s*/, "").trim();
|
|
1168
|
+
if (normalized.length > 0) {
|
|
1169
|
+
const capped = normalized.slice(0, 72).trim();
|
|
1170
|
+
return /[\u4e00-\u9fff]/.test(capped)
|
|
1171
|
+
? `实现${capped}`
|
|
1172
|
+
: `Implement ${capped}`;
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
return assignedRole === "developer"
|
|
1176
|
+
? "Implement the requested software deliverable"
|
|
1177
|
+
: `Perform the requested ${assignedRole || "software"} work`;
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
async function createControllerManagedTask(
|
|
1181
|
+
input: {
|
|
1182
|
+
title: string;
|
|
1183
|
+
description: string;
|
|
1184
|
+
priority?: TaskPriority;
|
|
1185
|
+
assignedRole?: RoleId;
|
|
1186
|
+
createdBy: string;
|
|
1187
|
+
controllerSessionKey?: string;
|
|
1188
|
+
recommendedSkills?: string[];
|
|
1189
|
+
},
|
|
1190
|
+
deps: ControllerHttpDeps,
|
|
1191
|
+
): Promise<TaskInfo | undefined> {
|
|
1192
|
+
const taskId = generateId();
|
|
1193
|
+
const now = Date.now();
|
|
1194
|
+
const repoState = await refreshControllerRepoState(deps);
|
|
1195
|
+
const normalizedSessionKey = input.createdBy === "controller" && input.controllerSessionKey
|
|
1196
|
+
? normalizeControllerIntakeSessionKey(input.controllerSessionKey)
|
|
1197
|
+
: undefined;
|
|
1198
|
+
|
|
1199
|
+
let projectDir: string | undefined;
|
|
1200
|
+
if (normalizedSessionKey) {
|
|
1201
|
+
const runId = findLatestControllerRunIdForSession(normalizedSessionKey, deps.getTeamState(), { preferActive: true });
|
|
1202
|
+
const parentRun = runId ? deps.getTeamState()?.controllerRuns[runId] : undefined;
|
|
1203
|
+
projectDir = parentRun?.projectDir;
|
|
1204
|
+
}
|
|
1205
|
+
if (!projectDir) {
|
|
1206
|
+
projectDir = deriveProjectSlug(input.title);
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
const normalizedRecommendedSkills = normalizeRecommendedSkills(input.recommendedSkills ?? []);
|
|
1210
|
+
const task: TaskInfo = {
|
|
1211
|
+
id: taskId,
|
|
1212
|
+
title: input.title,
|
|
1213
|
+
description: input.description,
|
|
1214
|
+
status: "pending",
|
|
1215
|
+
priority: input.priority ?? "medium",
|
|
1216
|
+
assignedRole: input.assignedRole,
|
|
1217
|
+
createdBy: input.createdBy,
|
|
1218
|
+
recommendedSkills: normalizedRecommendedSkills.length > 0 ? normalizedRecommendedSkills : undefined,
|
|
1219
|
+
controllerSessionKey: normalizedSessionKey,
|
|
1220
|
+
projectDir,
|
|
1221
|
+
createdAt: now,
|
|
1222
|
+
updatedAt: now,
|
|
1223
|
+
};
|
|
1224
|
+
|
|
1225
|
+
deps.updateTeamState((state) => {
|
|
1226
|
+
state.tasks[taskId] = task;
|
|
1227
|
+
});
|
|
1228
|
+
recordTaskExecutionEvent(taskId, {
|
|
1229
|
+
type: "lifecycle",
|
|
1230
|
+
phase: "created",
|
|
1231
|
+
source: "controller",
|
|
1232
|
+
status: "pending",
|
|
1233
|
+
message: `Task created by ${input.createdBy}.`,
|
|
1234
|
+
role: input.assignedRole,
|
|
1235
|
+
}, deps);
|
|
1236
|
+
if (repoState?.enabled) {
|
|
1237
|
+
recordTaskExecutionEvent(taskId, {
|
|
1238
|
+
type: "lifecycle",
|
|
1239
|
+
phase: "repo_ready",
|
|
1240
|
+
source: "controller",
|
|
1241
|
+
status: "pending",
|
|
1242
|
+
message: repoState.remoteReady && repoState.remoteUrl
|
|
1243
|
+
? `Git collaboration ready on ${repoState.defaultBranch} with remote ${repoState.remoteUrl}.`
|
|
1244
|
+
: `Git collaboration ready on ${repoState.defaultBranch} using controller-managed bundle sync.`,
|
|
1245
|
+
role: input.assignedRole,
|
|
1246
|
+
}, deps);
|
|
1247
|
+
}
|
|
1248
|
+
if (normalizedRecommendedSkills.length > 0) {
|
|
1249
|
+
recordTaskExecutionEvent(taskId, {
|
|
1250
|
+
type: "lifecycle",
|
|
1251
|
+
phase: "skills_recommended",
|
|
1252
|
+
source: "controller",
|
|
1253
|
+
status: "pending",
|
|
1254
|
+
message: `Recommended skills: ${normalizedRecommendedSkills.join(", ")}`,
|
|
1255
|
+
role: input.assignedRole,
|
|
1256
|
+
}, deps);
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
await autoAssignPendingTasks(deps);
|
|
1260
|
+
|
|
1261
|
+
const updatedTask = deps.getTeamState()?.tasks[taskId];
|
|
1262
|
+
deps.wsServer.broadcastUpdate({ type: "task:created", data: serializeTask(updatedTask) });
|
|
1263
|
+
return updatedTask;
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
async function maybeBackfillExecutionReadyTask(
|
|
1267
|
+
controllerRunId: string,
|
|
1268
|
+
sessionKey: string,
|
|
1269
|
+
request: string,
|
|
1270
|
+
manifest: ControllerOrchestrationManifest,
|
|
1271
|
+
deps: ControllerHttpDeps,
|
|
1272
|
+
options?: {
|
|
1273
|
+
source?: ControllerRunSource;
|
|
1274
|
+
},
|
|
1275
|
+
): Promise<string[]> {
|
|
1276
|
+
if (manifest.createdTasks.length > 0 || manifest.clarificationsNeeded || manifest.requirementFullyComplete) {
|
|
1277
|
+
return [];
|
|
1278
|
+
}
|
|
1279
|
+
if (options?.source === "task_follow_up") {
|
|
1280
|
+
return [];
|
|
1281
|
+
}
|
|
1282
|
+
if (!looksLikeSoftwareRequirement(request)) {
|
|
1283
|
+
return [];
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
const assignedRole = manifest.requiredRoles.includes("developer")
|
|
1287
|
+
? "developer"
|
|
1288
|
+
: manifest.requiredRoles[0];
|
|
1289
|
+
const task = await createControllerManagedTask({
|
|
1290
|
+
title: buildFallbackControllerTaskTitle(request, assignedRole),
|
|
1291
|
+
description: request,
|
|
1292
|
+
assignedRole,
|
|
1293
|
+
createdBy: "controller",
|
|
1294
|
+
controllerSessionKey: sessionKey,
|
|
1295
|
+
recommendedSkills: resolveRecommendedSkillsForRole(assignedRole, []),
|
|
1296
|
+
}, deps);
|
|
1297
|
+
if (!task) {
|
|
1298
|
+
return [];
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
manifest.createdTasks = [{
|
|
1302
|
+
title: task.title,
|
|
1303
|
+
assignedRole: task.assignedRole,
|
|
1304
|
+
expectedOutcome: summarizeManifestExpectedOutcome(task),
|
|
1305
|
+
}];
|
|
1306
|
+
manifest.notes = manifest.notes
|
|
1307
|
+
? `${manifest.notes} Controller synthesized a fallback execution-ready task because the model returned no created tasks.`
|
|
1308
|
+
: "Controller synthesized a fallback execution-ready task because the model returned no created tasks.";
|
|
1309
|
+
updateControllerRun(controllerRunId, deps, (run) => {
|
|
1310
|
+
run.manifest = manifest;
|
|
1311
|
+
appendControllerRunEvent(run, {
|
|
1312
|
+
type: "warning",
|
|
1313
|
+
phase: "task_backfilled",
|
|
1314
|
+
source: "controller",
|
|
1315
|
+
status: "running",
|
|
1316
|
+
sessionKey,
|
|
1317
|
+
message: `Controller synthesized fallback task ${task.id} (${task.assignedRole || "unassigned"}).`,
|
|
1318
|
+
});
|
|
1319
|
+
});
|
|
1320
|
+
return [task.id];
|
|
1321
|
+
}
|
|
1322
|
+
|
|
871
1323
|
function ensureControllerManifest(
|
|
872
1324
|
controllerRunId: string,
|
|
873
1325
|
sessionKey: string,
|
|
@@ -897,7 +1349,7 @@ function ensureControllerManifest(
|
|
|
897
1349
|
return manifest;
|
|
898
1350
|
}
|
|
899
1351
|
|
|
900
|
-
function buildControllerFollowUpMessage(task: TaskInfo): string {
|
|
1352
|
+
function buildControllerFollowUpMessage(task: TaskInfo, state: TeamState | null): string {
|
|
901
1353
|
const parts = [
|
|
902
1354
|
`A controller-created TeamClaw task has ${task.status === "failed" ? "failed" : "completed"}.`,
|
|
903
1355
|
`Task ID: ${task.id}`,
|
|
@@ -919,6 +1371,56 @@ function buildControllerFollowUpMessage(task: TaskInfo): string {
|
|
|
919
1371
|
parts.push("", "## Task Error", task.error);
|
|
920
1372
|
}
|
|
921
1373
|
|
|
1374
|
+
// Include preview URLs so the controller can present them to the human
|
|
1375
|
+
if (task.resultContract?.deliverables) {
|
|
1376
|
+
const liveDeliverables = task.resultContract.deliverables.filter((d) => d.liveUrl);
|
|
1377
|
+
if (liveDeliverables.length > 0) {
|
|
1378
|
+
parts.push("", "## Live Previews");
|
|
1379
|
+
for (const d of liveDeliverables) {
|
|
1380
|
+
parts.push(`- ${d.summary || d.value}: ${d.liveUrl}`);
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
// Inject prior manifest context (deferred tasks, clarification questions) so the
|
|
1386
|
+
// controller knows the original plan and can advance it
|
|
1387
|
+
if (state) {
|
|
1388
|
+
const sessionKey = task.controllerSessionKey;
|
|
1389
|
+
if (sessionKey) {
|
|
1390
|
+
const priorRuns = Object.values(state.controllerRuns)
|
|
1391
|
+
.filter((run) => normalizeControllerIntakeSessionKey(run.sessionKey) === normalizeControllerIntakeSessionKey(sessionKey) && run.manifest)
|
|
1392
|
+
.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
1393
|
+
|
|
1394
|
+
// Collect deferred tasks from the latest manifest that had them
|
|
1395
|
+
const latestWithDeferred = priorRuns.find((run) => run.manifest!.deferredTasks && run.manifest!.deferredTasks.length > 0);
|
|
1396
|
+
if (latestWithDeferred?.manifest?.deferredTasks) {
|
|
1397
|
+
parts.push("", "## Prior Orchestration Plan (Deferred Tasks)");
|
|
1398
|
+
parts.push("These tasks were identified in the original plan but deferred until prerequisites were met:");
|
|
1399
|
+
for (const dt of latestWithDeferred.manifest.deferredTasks) {
|
|
1400
|
+
const blockedBy = dt.blockedBy ? ` [blocked by: ${dt.blockedBy}]` : "";
|
|
1401
|
+
parts.push(`- ${dt.title} (${dt.assignedRole || "any"})${blockedBy}`);
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
// Collect only still-pending clarification questions for this session.
|
|
1406
|
+
const pendingClarifications = Object.values(state.clarifications)
|
|
1407
|
+
.filter((clarification) =>
|
|
1408
|
+
clarification.status === "pending"
|
|
1409
|
+
&& normalizeControllerIntakeSessionKey(clarification.controllerSessionKey) === normalizeControllerIntakeSessionKey(sessionKey),
|
|
1410
|
+
)
|
|
1411
|
+
.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
1412
|
+
const priorQuestions = pendingClarifications
|
|
1413
|
+
.map((clarification) => clarification.question)
|
|
1414
|
+
.filter(Boolean);
|
|
1415
|
+
if (priorQuestions.length > 0) {
|
|
1416
|
+
parts.push("", "## Prior Clarification Questions (from earlier runs)");
|
|
1417
|
+
for (const q of priorQuestions.slice(0, 5)) {
|
|
1418
|
+
parts.push(`- ${q}`);
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
|
|
922
1424
|
parts.push(
|
|
923
1425
|
"",
|
|
924
1426
|
"## Controller Follow-up",
|
|
@@ -926,12 +1428,184 @@ function buildControllerFollowUpMessage(task: TaskInfo): string {
|
|
|
926
1428
|
"Review the current TeamClaw state before acting.",
|
|
927
1429
|
"Create only the next execution-ready task(s) whose prerequisites are now satisfied.",
|
|
928
1430
|
"Do not duplicate tasks that already exist, are active, or are already completed.",
|
|
1431
|
+
"If this task produced a web application with a live preview URL, include it in your reply so the human can verify the result.",
|
|
1432
|
+
"If all planned phases are complete and no follow-ups remain, set requirementFullyComplete=true in the manifest and provide a final delivery summary.",
|
|
929
1433
|
"If no additional task should be created yet, reply briefly and stop.",
|
|
930
1434
|
);
|
|
931
1435
|
|
|
932
1436
|
return parts.filter(Boolean).join("\n");
|
|
933
1437
|
}
|
|
934
1438
|
|
|
1439
|
+
function buildControllerClarificationAnswerMessage(
|
|
1440
|
+
clarification: ClarificationRequest,
|
|
1441
|
+
answer: string,
|
|
1442
|
+
answeredBy: string,
|
|
1443
|
+
): string {
|
|
1444
|
+
return [
|
|
1445
|
+
"The human has answered a pending controller clarification.",
|
|
1446
|
+
`Question: ${clarification.question}`,
|
|
1447
|
+
`Answer: ${answer}`,
|
|
1448
|
+
`Answered by: ${answeredBy}`,
|
|
1449
|
+
"Use this answer to continue the same requirement and update the plan/tasks accordingly.",
|
|
1450
|
+
].join("\n");
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
function buildStructuredClarificationAnswer(
|
|
1454
|
+
clarification: ClarificationRequest,
|
|
1455
|
+
payload: {
|
|
1456
|
+
answer?: string;
|
|
1457
|
+
answerValue?: string;
|
|
1458
|
+
answerValues?: string[];
|
|
1459
|
+
answerNumber?: number;
|
|
1460
|
+
answerComment?: string;
|
|
1461
|
+
},
|
|
1462
|
+
): string {
|
|
1463
|
+
if (typeof payload.answer === "string" && payload.answer.trim()) {
|
|
1464
|
+
return payload.answer.trim();
|
|
1465
|
+
}
|
|
1466
|
+
const schema = clarification.questionSchema;
|
|
1467
|
+
const optionLabel = (value: string): string => {
|
|
1468
|
+
const match = schema?.options?.find((entry) => entry.value === value);
|
|
1469
|
+
return match?.label || value;
|
|
1470
|
+
};
|
|
1471
|
+
const parts: string[] = [];
|
|
1472
|
+
if (schema?.kind === "single-select" && payload.answerValue) {
|
|
1473
|
+
parts.push(optionLabel(payload.answerValue));
|
|
1474
|
+
} else if (schema?.kind === "multi-select" && Array.isArray(payload.answerValues) && payload.answerValues.length > 0) {
|
|
1475
|
+
parts.push(payload.answerValues.map((entry) => optionLabel(entry)).join(", "));
|
|
1476
|
+
} else if (schema?.kind === "number" && typeof payload.answerNumber === "number" && Number.isFinite(payload.answerNumber)) {
|
|
1477
|
+
parts.push(`${payload.answerNumber}${schema.unit ? ` ${schema.unit}` : ""}`);
|
|
1478
|
+
} else if (payload.answerValue) {
|
|
1479
|
+
parts.push(payload.answerValue);
|
|
1480
|
+
}
|
|
1481
|
+
if (payload.answerComment && payload.answerComment.trim()) {
|
|
1482
|
+
parts.push(parts.length > 0 ? `Additional context: ${payload.answerComment.trim()}` : payload.answerComment.trim());
|
|
1483
|
+
}
|
|
1484
|
+
return parts.join("\n").trim();
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
function normalizeComparableText(value: string): string {
|
|
1488
|
+
return value
|
|
1489
|
+
.toLowerCase()
|
|
1490
|
+
.replace(/\s+/g, " ")
|
|
1491
|
+
.trim();
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
function buildManifestClarificationEntries(
|
|
1495
|
+
manifest: ControllerOrchestrationManifest,
|
|
1496
|
+
): Array<{ question: string; questionSchema?: ClarificationRequest["questionSchema"] }> {
|
|
1497
|
+
const normalizedQuestions = manifest.clarificationQuestions
|
|
1498
|
+
.map((entry) => String(entry || "").trim())
|
|
1499
|
+
.filter(Boolean);
|
|
1500
|
+
const schemas = Array.isArray(manifest.clarificationSchemas) ? manifest.clarificationSchemas : [];
|
|
1501
|
+
if (schemas.length > 0) {
|
|
1502
|
+
return schemas.map((schema, index) => ({
|
|
1503
|
+
question: normalizedQuestions[index] || schema.title,
|
|
1504
|
+
questionSchema: schema,
|
|
1505
|
+
}));
|
|
1506
|
+
}
|
|
1507
|
+
return normalizedQuestions.map((question) => ({ question }));
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
function syncControllerRunClarifications(
|
|
1511
|
+
controllerRunId: string,
|
|
1512
|
+
sessionKey: string,
|
|
1513
|
+
manifest: ControllerOrchestrationManifest,
|
|
1514
|
+
deps: ControllerHttpDeps,
|
|
1515
|
+
): ClarificationRequest[] {
|
|
1516
|
+
const entries = buildManifestClarificationEntries(manifest);
|
|
1517
|
+
if (entries.length === 0) {
|
|
1518
|
+
return [];
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
const created: ClarificationRequest[] = [];
|
|
1522
|
+
const now = Date.now();
|
|
1523
|
+
deps.updateTeamState((state) => {
|
|
1524
|
+
const existingQuestions = new Map(
|
|
1525
|
+
Object.values(state.clarifications)
|
|
1526
|
+
.filter((item) => item.controllerRunId === controllerRunId)
|
|
1527
|
+
.map((item) => [normalizeComparableText(item.question), item]),
|
|
1528
|
+
);
|
|
1529
|
+
|
|
1530
|
+
for (const entry of entries) {
|
|
1531
|
+
const key = normalizeComparableText(entry.question);
|
|
1532
|
+
if (existingQuestions.has(key)) {
|
|
1533
|
+
continue;
|
|
1534
|
+
}
|
|
1535
|
+
const clarification: ClarificationRequest = {
|
|
1536
|
+
id: generateId(),
|
|
1537
|
+
taskId: "",
|
|
1538
|
+
controllerRunId,
|
|
1539
|
+
controllerSessionKey: sessionKey,
|
|
1540
|
+
requestedBy: "controller",
|
|
1541
|
+
question: entry.question,
|
|
1542
|
+
questionSchema: entry.questionSchema,
|
|
1543
|
+
blockingReason: "The controller needs this information before it can confidently continue planning and downstream task creation.",
|
|
1544
|
+
context: manifest.requirementSummary,
|
|
1545
|
+
status: "pending",
|
|
1546
|
+
createdAt: now,
|
|
1547
|
+
updatedAt: now,
|
|
1548
|
+
};
|
|
1549
|
+
state.clarifications[clarification.id] = clarification;
|
|
1550
|
+
created.push(clarification);
|
|
1551
|
+
}
|
|
1552
|
+
});
|
|
1553
|
+
|
|
1554
|
+
for (const clarification of created) {
|
|
1555
|
+
deps.wsServer.broadcastUpdate({ type: "clarification:requested", data: clarification });
|
|
1556
|
+
}
|
|
1557
|
+
return created;
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
function reconcileControllerClarifications(deps: ControllerHttpDeps): TeamState | null {
|
|
1561
|
+
const state = deps.getTeamState();
|
|
1562
|
+
if (!state) {
|
|
1563
|
+
return null;
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
deps.updateTeamState((draft) => {
|
|
1567
|
+
const runsBySession = new Map<string, ControllerRunInfo[]>();
|
|
1568
|
+
for (const run of Object.values(draft.controllerRuns)) {
|
|
1569
|
+
const normalizedSessionKey = normalizeControllerIntakeSessionKey(run.sessionKey);
|
|
1570
|
+
const bucket = runsBySession.get(normalizedSessionKey) ?? [];
|
|
1571
|
+
bucket.push(run);
|
|
1572
|
+
runsBySession.set(normalizedSessionKey, bucket);
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
for (const clar of Object.values(draft.clarifications)) {
|
|
1576
|
+
if (clar.status !== "pending" || clar.requestedBy !== "controller" || clar.taskId) {
|
|
1577
|
+
continue;
|
|
1578
|
+
}
|
|
1579
|
+
const normalizedSessionKey = normalizeControllerIntakeSessionKey(clar.controllerSessionKey);
|
|
1580
|
+
const sessionRuns = runsBySession.get(normalizedSessionKey) ?? [];
|
|
1581
|
+
const supersedingRun = sessionRuns.find((run) =>
|
|
1582
|
+
run.updatedAt > clar.updatedAt
|
|
1583
|
+
&& (
|
|
1584
|
+
run.manifest?.createdTasks.length
|
|
1585
|
+
|| run.manifest?.requirementFullyComplete
|
|
1586
|
+
),
|
|
1587
|
+
);
|
|
1588
|
+
if (!supersedingRun) {
|
|
1589
|
+
continue;
|
|
1590
|
+
}
|
|
1591
|
+
clar.status = "answered";
|
|
1592
|
+
clar.answer = "Automatically superseded by later controller progress.";
|
|
1593
|
+
clar.answeredBy = "system";
|
|
1594
|
+
clar.answeredAt = supersedingRun.updatedAt;
|
|
1595
|
+
clar.updatedAt = supersedingRun.updatedAt;
|
|
1596
|
+
}
|
|
1597
|
+
});
|
|
1598
|
+
|
|
1599
|
+
for (const run of Object.values(state.controllerRuns)) {
|
|
1600
|
+
if (!run.manifest?.clarificationsNeeded) {
|
|
1601
|
+
continue;
|
|
1602
|
+
}
|
|
1603
|
+
syncControllerRunClarifications(run.id, run.sessionKey, run.manifest, deps);
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
return deps.getTeamState();
|
|
1607
|
+
}
|
|
1608
|
+
|
|
935
1609
|
function buildControllerRateLimitProbeMessage(
|
|
936
1610
|
sourceTaskId?: string,
|
|
937
1611
|
sourceTaskTitle?: string,
|
|
@@ -949,6 +1623,64 @@ function buildControllerRateLimitProbeMessage(
|
|
|
949
1623
|
].join("\n");
|
|
950
1624
|
}
|
|
951
1625
|
|
|
1626
|
+
function buildControllerInactivityProbeMessage(
|
|
1627
|
+
inactivityMs: number,
|
|
1628
|
+
sourceTaskId?: string,
|
|
1629
|
+
sourceTaskTitle?: string,
|
|
1630
|
+
): string {
|
|
1631
|
+
const workflowLabel = sourceTaskTitle
|
|
1632
|
+
? `${sourceTaskTitle}${sourceTaskId ? ` (${sourceTaskId})` : ""}`
|
|
1633
|
+
: (sourceTaskId ? `task ${sourceTaskId}` : "this controller workflow");
|
|
1634
|
+
return [
|
|
1635
|
+
`This is a follow-up check for ${workflowLabel}.`,
|
|
1636
|
+
`There has been no new visible controller workflow progress for over ${formatDuration(inactivityMs)}.`,
|
|
1637
|
+
"Do not restart the workflow from scratch.",
|
|
1638
|
+
"Do not duplicate tasks that already exist, are active, or are completed.",
|
|
1639
|
+
"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.",
|
|
1640
|
+
`If the earlier controller follow-up is not complete yet, reply with exactly ${CONTROLLER_RATE_LIMIT_WAITING_SENTINEL}.`,
|
|
1641
|
+
].join("\n");
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
async function checkAndGenerateReport(task: TaskInfo, deps: ControllerHttpDeps): Promise<void> {
|
|
1645
|
+
const sessionKey = task.controllerSessionKey;
|
|
1646
|
+
if (!sessionKey) return;
|
|
1647
|
+
|
|
1648
|
+
const state = deps.getTeamState();
|
|
1649
|
+
if (!state) return;
|
|
1650
|
+
|
|
1651
|
+
if (!isSessionComplete(sessionKey, state, normalizeControllerIntakeSessionKey)) return;
|
|
1652
|
+
|
|
1653
|
+
const report = generateDeliveryReport(sessionKey, state, normalizeControllerIntakeSessionKey);
|
|
1654
|
+
if (!report) return;
|
|
1655
|
+
|
|
1656
|
+
// Check if we already generated a report for this session
|
|
1657
|
+
const existingReport = state.reports?.[report.id];
|
|
1658
|
+
if (existingReport) return;
|
|
1659
|
+
|
|
1660
|
+
// Store a lightweight record in state (full report is generated on demand from live state)
|
|
1661
|
+
deps.updateTeamState((s) => {
|
|
1662
|
+
if (!s.reports) s.reports = {};
|
|
1663
|
+
s.reports[report.id] = {
|
|
1664
|
+
id: report.id,
|
|
1665
|
+
sessionKey: report.sessionKey,
|
|
1666
|
+
generatedAt: report.generatedAt,
|
|
1667
|
+
projectName: report.projectName,
|
|
1668
|
+
requirementSummary: report.requirementSummary,
|
|
1669
|
+
status: report.status,
|
|
1670
|
+
taskCount: report.taskCount,
|
|
1671
|
+
deliverableCount: report.deliverables.length,
|
|
1672
|
+
previewCount: report.deliverables.filter((d) => d.previewUrl).length,
|
|
1673
|
+
};
|
|
1674
|
+
});
|
|
1675
|
+
|
|
1676
|
+
const reportUrl = `/api/v1/reports/${encodeURIComponent(sessionKey)}`;
|
|
1677
|
+
deps.wsServer.broadcastUpdate({
|
|
1678
|
+
type: "report:ready",
|
|
1679
|
+
data: { sessionKey, reportUrl, projectName: report.projectName, status: report.status },
|
|
1680
|
+
});
|
|
1681
|
+
deps.logger.info(`Controller: delivery report generated for session ${sessionKey}: ${reportUrl}`);
|
|
1682
|
+
}
|
|
1683
|
+
|
|
952
1684
|
async function continueControllerWorkflow(task: TaskInfo, deps: ControllerHttpDeps): Promise<void> {
|
|
953
1685
|
if (task.createdBy !== "controller") {
|
|
954
1686
|
return;
|
|
@@ -965,7 +1697,7 @@ async function continueControllerWorkflow(task: TaskInfo, deps: ControllerHttpDe
|
|
|
965
1697
|
}
|
|
966
1698
|
});
|
|
967
1699
|
}
|
|
968
|
-
await runControllerIntake(buildControllerFollowUpMessage(task), sessionKey, deps, {
|
|
1700
|
+
await runControllerIntake(buildControllerFollowUpMessage(task, deps.getTeamState()), sessionKey, deps, {
|
|
969
1701
|
source: "task_follow_up",
|
|
970
1702
|
sourceTaskId: task.id,
|
|
971
1703
|
sourceTaskTitle: task.title,
|
|
@@ -983,9 +1715,32 @@ async function runControllerIntake(
|
|
|
983
1715
|
},
|
|
984
1716
|
): Promise<{ sessionKey: string; runId: string; reply: string; controllerRunId: string }> {
|
|
985
1717
|
const normalizedSessionKey = normalizeControllerIntakeSessionKey(sessionKey);
|
|
986
|
-
return withSerializedControllerIntake(normalizedSessionKey, () =>
|
|
987
|
-
|
|
988
|
-
|
|
1718
|
+
return withSerializedControllerIntake(normalizedSessionKey, async () => {
|
|
1719
|
+
let lastError: Error | null = null;
|
|
1720
|
+
for (let attempt = 0; attempt <= CONTROLLER_INTAKE_MAX_RETRIES; attempt++) {
|
|
1721
|
+
try {
|
|
1722
|
+
return await runControllerIntakeUnlocked(message, normalizedSessionKey, deps, options);
|
|
1723
|
+
} catch (err) {
|
|
1724
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
1725
|
+
const errorText = lastError.message;
|
|
1726
|
+
// Only retry on transient server errors (500/502/503), not on client errors,
|
|
1727
|
+
// timeouts, or other failures that retrying won't fix.
|
|
1728
|
+
if (
|
|
1729
|
+
attempt === CONTROLLER_INTAKE_MAX_RETRIES
|
|
1730
|
+
|| !CONTROLLER_INTAKE_RETRYABLE_ERROR_PATTERN.test(errorText)
|
|
1731
|
+
|| errorText.includes("timed out")
|
|
1732
|
+
) {
|
|
1733
|
+
throw lastError;
|
|
1734
|
+
}
|
|
1735
|
+
// Record a visible retry event so the human can see what happened.
|
|
1736
|
+
deps.logger.warn(
|
|
1737
|
+
`Controller: intake attempt ${attempt + 1} failed with transient error: ${errorText.slice(0, 200)}. Retrying in ${CONTROLLER_INTAKE_RETRY_DELAY_MS * (attempt + 1) / 1000}s...`,
|
|
1738
|
+
);
|
|
1739
|
+
await new Promise<void>((resolve) => setTimeout(resolve, CONTROLLER_INTAKE_RETRY_DELAY_MS * (attempt + 1)));
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
throw lastError!;
|
|
1743
|
+
});
|
|
989
1744
|
}
|
|
990
1745
|
|
|
991
1746
|
async function runControllerIntakeUnlocked(
|
|
@@ -1034,6 +1789,16 @@ async function runControllerIntakeUnlocked(
|
|
|
1034
1789
|
active: false,
|
|
1035
1790
|
probeCount: 0,
|
|
1036
1791
|
};
|
|
1792
|
+
const inactivityState: {
|
|
1793
|
+
active: boolean;
|
|
1794
|
+
visibleAt?: number;
|
|
1795
|
+
nextProbeAt?: number;
|
|
1796
|
+
probeCount: number;
|
|
1797
|
+
} = {
|
|
1798
|
+
active: false,
|
|
1799
|
+
nextProbeAt: Date.now() + deps.config.taskTimeoutMs,
|
|
1800
|
+
probeCount: 0,
|
|
1801
|
+
};
|
|
1037
1802
|
|
|
1038
1803
|
const markRateLimitWaiting = async (): Promise<void> => {
|
|
1039
1804
|
if (rateLimitState.active) {
|
|
@@ -1060,6 +1825,12 @@ async function runControllerIntakeUnlocked(
|
|
|
1060
1825
|
rateLimitState.nextProbeAt = undefined;
|
|
1061
1826
|
};
|
|
1062
1827
|
|
|
1828
|
+
const noteObservedControllerActivity = (): void => {
|
|
1829
|
+
inactivityState.active = false;
|
|
1830
|
+
inactivityState.visibleAt = undefined;
|
|
1831
|
+
inactivityState.nextProbeAt = Date.now() + deps.config.taskTimeoutMs;
|
|
1832
|
+
};
|
|
1833
|
+
|
|
1063
1834
|
const extractSessionAssistantReply = async (): Promise<string> => {
|
|
1064
1835
|
const sessionMessages = await deps.runtime.subagent.getSessionMessages({
|
|
1065
1836
|
sessionKey,
|
|
@@ -1113,19 +1884,76 @@ async function runControllerIntakeUnlocked(
|
|
|
1113
1884
|
}
|
|
1114
1885
|
|
|
1115
1886
|
clearRateLimitWaiting();
|
|
1887
|
+
noteObservedControllerActivity();
|
|
1888
|
+
return probeReply;
|
|
1889
|
+
};
|
|
1890
|
+
|
|
1891
|
+
const probeInactiveControllerCompletion = async (): Promise<string | null> => {
|
|
1892
|
+
inactivityState.probeCount += 1;
|
|
1893
|
+
const now = Date.now();
|
|
1894
|
+
inactivityState.active = true;
|
|
1895
|
+
inactivityState.visibleAt = now;
|
|
1896
|
+
inactivityState.nextProbeAt = now + deps.config.taskTimeoutMs;
|
|
1897
|
+
recordControllerRunEvent(controllerRun.id, {
|
|
1898
|
+
type: "progress",
|
|
1899
|
+
phase: "inactivity_probe",
|
|
1900
|
+
source: "controller",
|
|
1901
|
+
status: "running",
|
|
1902
|
+
sessionKey,
|
|
1903
|
+
runId: runResult.runId,
|
|
1904
|
+
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.`,
|
|
1905
|
+
}, deps);
|
|
1906
|
+
|
|
1907
|
+
const probeRun = await deps.runtime.subagent.run({
|
|
1908
|
+
sessionKey,
|
|
1909
|
+
message: buildControllerInactivityProbeMessage(
|
|
1910
|
+
deps.config.taskTimeoutMs,
|
|
1911
|
+
options?.sourceTaskId,
|
|
1912
|
+
options?.sourceTaskTitle,
|
|
1913
|
+
),
|
|
1914
|
+
extraSystemPrompt: buildControllerIntakeSystemPrompt(deps),
|
|
1915
|
+
idempotencyKey: `${runResult.runId}:inactivity-probe:${inactivityState.probeCount}`,
|
|
1916
|
+
});
|
|
1917
|
+
const probeWait = await deps.runtime.subagent.waitForRun({
|
|
1918
|
+
runId: probeRun.runId,
|
|
1919
|
+
timeoutMs: CONTROLLER_INACTIVITY_PROBE_TIMEOUT_MS,
|
|
1920
|
+
});
|
|
1921
|
+
|
|
1922
|
+
if (probeWait.status === "error" && isRateLimitMessage(probeWait.error || "")) {
|
|
1923
|
+
await markRateLimitWaiting();
|
|
1924
|
+
return null;
|
|
1925
|
+
}
|
|
1926
|
+
if (probeWait.status !== "ok") {
|
|
1927
|
+
return null;
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
const probeReply = await extractSessionAssistantReply();
|
|
1931
|
+
if (!probeReply || isRateLimitMessage(probeReply) || isStillWaitingResponse(probeReply)) {
|
|
1932
|
+
inactivityState.active = false;
|
|
1933
|
+
inactivityState.visibleAt = undefined;
|
|
1934
|
+
inactivityState.nextProbeAt = Date.now() + deps.config.taskTimeoutMs;
|
|
1935
|
+
recordControllerRunEvent(controllerRun.id, {
|
|
1936
|
+
type: "progress",
|
|
1937
|
+
phase: "inactivity_still_waiting",
|
|
1938
|
+
source: "controller",
|
|
1939
|
+
status: "running",
|
|
1940
|
+
sessionKey,
|
|
1941
|
+
runId: runResult.runId,
|
|
1942
|
+
message: "The controller workflow is still running without a final result yet. TeamClaw will keep waiting instead of failing the run.",
|
|
1943
|
+
}, deps);
|
|
1944
|
+
return null;
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
if (rateLimitState.active) {
|
|
1948
|
+
clearRateLimitWaiting();
|
|
1949
|
+
}
|
|
1950
|
+
noteObservedControllerActivity();
|
|
1116
1951
|
return probeReply;
|
|
1117
1952
|
};
|
|
1118
1953
|
|
|
1119
1954
|
let waitResult: Awaited<ReturnType<typeof deps.runtime.subagent.waitForRun>> = { status: "timeout" };
|
|
1120
1955
|
let completionOverride: string | null = null;
|
|
1121
|
-
const deadline = Date.now() + deps.config.taskTimeoutMs;
|
|
1122
1956
|
while (true) {
|
|
1123
|
-
const remainingMs = deadline - Date.now();
|
|
1124
|
-
if (remainingMs <= 0) {
|
|
1125
|
-
waitResult = { status: "timeout" };
|
|
1126
|
-
break;
|
|
1127
|
-
}
|
|
1128
|
-
|
|
1129
1957
|
if (rateLimitState.active && (rateLimitState.nextProbeAt ?? Number.POSITIVE_INFINITY) <= Date.now()) {
|
|
1130
1958
|
completionOverride = await probeRateLimitedControllerCompletion();
|
|
1131
1959
|
if (completionOverride) {
|
|
@@ -1134,14 +1962,22 @@ async function runControllerIntakeUnlocked(
|
|
|
1134
1962
|
}
|
|
1135
1963
|
}
|
|
1136
1964
|
|
|
1137
|
-
|
|
1965
|
+
if (!rateLimitState.active && (inactivityState.nextProbeAt ?? Number.POSITIVE_INFINITY) <= Date.now()) {
|
|
1966
|
+
completionOverride = await probeInactiveControllerCompletion();
|
|
1967
|
+
if (completionOverride) {
|
|
1968
|
+
waitResult = { status: "ok" };
|
|
1969
|
+
break;
|
|
1970
|
+
}
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1138
1973
|
waitResult = await deps.runtime.subagent.waitForRun({
|
|
1139
1974
|
runId: runResult.runId,
|
|
1140
|
-
timeoutMs:
|
|
1975
|
+
timeoutMs: CONTROLLER_RUN_WAIT_SLICE_MS,
|
|
1141
1976
|
});
|
|
1142
1977
|
|
|
1143
1978
|
if (waitResult.status === "ok") {
|
|
1144
1979
|
clearRateLimitWaiting();
|
|
1980
|
+
noteObservedControllerActivity();
|
|
1145
1981
|
break;
|
|
1146
1982
|
}
|
|
1147
1983
|
if (waitResult.status === "error") {
|
|
@@ -1152,24 +1988,6 @@ async function runControllerIntakeUnlocked(
|
|
|
1152
1988
|
break;
|
|
1153
1989
|
}
|
|
1154
1990
|
}
|
|
1155
|
-
|
|
1156
|
-
if (waitResult.status === "timeout") {
|
|
1157
|
-
const createdTaskIds = tagControllerCreatedTasks(taskIdsBeforeRun, sessionKey, deps);
|
|
1158
|
-
updateControllerRun(controllerRun.id, deps, (run) => {
|
|
1159
|
-
run.createdTaskIds = createdTaskIds;
|
|
1160
|
-
run.error = "Controller intake timed out";
|
|
1161
|
-
appendControllerRunEvent(run, {
|
|
1162
|
-
type: "error",
|
|
1163
|
-
phase: "timeout",
|
|
1164
|
-
source: "controller",
|
|
1165
|
-
status: "failed",
|
|
1166
|
-
sessionKey,
|
|
1167
|
-
runId: runResult.runId,
|
|
1168
|
-
message: "Controller intake timed out.",
|
|
1169
|
-
});
|
|
1170
|
-
});
|
|
1171
|
-
throw new Error("Controller intake timed out");
|
|
1172
|
-
}
|
|
1173
1991
|
if (waitResult.status !== "ok") {
|
|
1174
1992
|
const errorMessage = waitResult.error || "Controller intake failed";
|
|
1175
1993
|
const createdTaskIds = tagControllerCreatedTasks(taskIdsBeforeRun, sessionKey, deps);
|
|
@@ -1189,7 +2007,7 @@ async function runControllerIntakeUnlocked(
|
|
|
1189
2007
|
throw new Error(errorMessage);
|
|
1190
2008
|
}
|
|
1191
2009
|
|
|
1192
|
-
|
|
2010
|
+
let createdTaskIds = tagControllerCreatedTasks(taskIdsBeforeRun, sessionKey, deps);
|
|
1193
2011
|
|
|
1194
2012
|
const rawReply = completionOverride || await extractSessionAssistantReply()
|
|
1195
2013
|
|| "Controller completed the intake run but did not return any text.";
|
|
@@ -1201,7 +2019,88 @@ async function runControllerIntakeUnlocked(
|
|
|
1201
2019
|
createdTaskIds,
|
|
1202
2020
|
deps,
|
|
1203
2021
|
);
|
|
2022
|
+
const fallbackTaskIds = await maybeBackfillExecutionReadyTask(
|
|
2023
|
+
controllerRun.id,
|
|
2024
|
+
sessionKey,
|
|
2025
|
+
message,
|
|
2026
|
+
recordedManifest,
|
|
2027
|
+
deps,
|
|
2028
|
+
{ source: options?.source },
|
|
2029
|
+
);
|
|
2030
|
+
if (fallbackTaskIds.length > 0) {
|
|
2031
|
+
createdTaskIds = Array.from(new Set([...createdTaskIds, ...fallbackTaskIds]));
|
|
2032
|
+
}
|
|
2033
|
+
syncControllerRunClarifications(controllerRun.id, sessionKey, recordedManifest, deps);
|
|
1204
2034
|
const reconciledTasks = reconcileControllerManifestTaskBindings(sessionKey, createdTaskIds, recordedManifest, deps);
|
|
2035
|
+
|
|
2036
|
+
// ── Automatic Team Kickoff ────────────────────────────────────────────
|
|
2037
|
+
// For complex projects (3+ roles), automatically run a kickoff meeting
|
|
2038
|
+
// so every candidate role can assess the requirement collaboratively.
|
|
2039
|
+
// This happens AFTER the initial LLM pass so we know which roles are
|
|
2040
|
+
// needed, but we store the assessments for visibility and future
|
|
2041
|
+
// refinement passes.
|
|
2042
|
+
const AUTO_KICKOFF_ROLE_THRESHOLD = 3;
|
|
2043
|
+
const isFollowUp = options?.source === "task_follow_up";
|
|
2044
|
+
const kickoffHandler = deps.getKickoffHandler?.();
|
|
2045
|
+
if (
|
|
2046
|
+
!isFollowUp
|
|
2047
|
+
&& kickoffHandler
|
|
2048
|
+
&& recordedManifest.requiredRoles.length >= AUTO_KICKOFF_ROLE_THRESHOLD
|
|
2049
|
+
&& !recordedManifest.clarificationsNeeded
|
|
2050
|
+
) {
|
|
2051
|
+
deps.logger.info(
|
|
2052
|
+
`Controller: auto-kickoff triggered for ${recordedManifest.requiredRoles.length} roles: ${recordedManifest.requiredRoles.join(", ")}`,
|
|
2053
|
+
);
|
|
2054
|
+
recordControllerRunEvent(controllerRun.id, {
|
|
2055
|
+
type: "lifecycle",
|
|
2056
|
+
phase: "kickoff_started",
|
|
2057
|
+
source: "controller",
|
|
2058
|
+
status: "running",
|
|
2059
|
+
sessionKey,
|
|
2060
|
+
message: `Auto-kickoff: provisioning ${recordedManifest.requiredRoles.length} candidate roles for team assessment.`,
|
|
2061
|
+
}, deps);
|
|
2062
|
+
try {
|
|
2063
|
+
const kickoffResult = await kickoffHandler(
|
|
2064
|
+
recordedManifest.requiredRoles as RoleId[],
|
|
2065
|
+
recordedManifest.requiredRoles.length >= 5 ? "complex" : "medium",
|
|
2066
|
+
message,
|
|
2067
|
+
);
|
|
2068
|
+
// Store kickoff assessments on the manifest for UI/API visibility
|
|
2069
|
+
recordedManifest.kickoffPlan = {
|
|
2070
|
+
assessments: kickoffResult.assessments,
|
|
2071
|
+
summary: kickoffResult.summary,
|
|
2072
|
+
triggeredAt: Date.now(),
|
|
2073
|
+
};
|
|
2074
|
+
updateControllerRun(controllerRun.id, deps, (run) => {
|
|
2075
|
+
if (run.manifest) {
|
|
2076
|
+
run.manifest.kickoffPlan = recordedManifest.kickoffPlan;
|
|
2077
|
+
}
|
|
2078
|
+
appendControllerRunEvent(run, {
|
|
2079
|
+
type: "lifecycle",
|
|
2080
|
+
phase: "kickoff_completed",
|
|
2081
|
+
source: "controller",
|
|
2082
|
+
status: "running",
|
|
2083
|
+
sessionKey,
|
|
2084
|
+
message: `Team kickoff completed: ${kickoffResult.assessments.length} assessments collected. ${kickoffResult.summary.slice(0, 200)}`,
|
|
2085
|
+
});
|
|
2086
|
+
});
|
|
2087
|
+
deps.logger.info(
|
|
2088
|
+
`Controller: auto-kickoff completed with ${kickoffResult.assessments.length} assessments`,
|
|
2089
|
+
);
|
|
2090
|
+
} catch (err) {
|
|
2091
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
2092
|
+
deps.logger.warn(`Controller: auto-kickoff failed: ${errMsg}`);
|
|
2093
|
+
recordControllerRunEvent(controllerRun.id, {
|
|
2094
|
+
type: "lifecycle",
|
|
2095
|
+
phase: "kickoff_failed",
|
|
2096
|
+
source: "controller",
|
|
2097
|
+
status: "running",
|
|
2098
|
+
sessionKey,
|
|
2099
|
+
message: `Auto-kickoff failed: ${errMsg}. Proceeding with controller-only planning.`,
|
|
2100
|
+
}, deps);
|
|
2101
|
+
}
|
|
2102
|
+
}
|
|
2103
|
+
|
|
1205
2104
|
const latestTeamState = deps.getTeamState();
|
|
1206
2105
|
const reply = buildControllerManifestReply(recordedManifest, reconciledTasks.taskIds, latestTeamState, rawReply);
|
|
1207
2106
|
|
|
@@ -1283,8 +2182,15 @@ function buildRecentCompletedTaskContext(task: TaskInfo, state: TeamState | null
|
|
|
1283
2182
|
return "";
|
|
1284
2183
|
}
|
|
1285
2184
|
|
|
2185
|
+
// Only include tasks from the same session/project to avoid cross-project contamination.
|
|
2186
|
+
// Tasks sharing the same controllerSessionKey belong to the same user requirement.
|
|
2187
|
+
const sameSession = task.controllerSessionKey
|
|
2188
|
+
? (candidate: TaskInfo) => candidate.controllerSessionKey === task.controllerSessionKey
|
|
2189
|
+
: () => true; // If no session key, fall back to all completed tasks (legacy behavior)
|
|
2190
|
+
|
|
1286
2191
|
const recentCompletedTasks = Object.values(state.tasks)
|
|
1287
2192
|
.filter((candidate) => candidate.id !== task.id && candidate.status === "completed")
|
|
2193
|
+
.filter(sameSession)
|
|
1288
2194
|
.filter((candidate) => (candidate.completedAt ?? candidate.updatedAt) <= task.createdAt)
|
|
1289
2195
|
.sort((a, b) => (b.completedAt ?? b.updatedAt) - (a.completedAt ?? a.updatedAt))
|
|
1290
2196
|
.slice(0, MAX_RECENT_TASK_CONTEXT)
|
|
@@ -1325,10 +2231,18 @@ function buildRecommendedSkillsContext(task: TaskInfo): string {
|
|
|
1325
2231
|
|
|
1326
2232
|
function buildTaskAssignmentDescription(task: TaskInfo, state: TeamState | null, repoInfo?: RepoSyncInfo): string {
|
|
1327
2233
|
const parts = [task.description];
|
|
2234
|
+
const projectContext = buildProjectDirectoryContext(task.projectDir);
|
|
2235
|
+
if (projectContext) {
|
|
2236
|
+
parts.push("", projectContext);
|
|
2237
|
+
}
|
|
1328
2238
|
const recommendedSkillsContext = buildRecommendedSkillsContext(task);
|
|
1329
2239
|
if (recommendedSkillsContext) {
|
|
1330
2240
|
parts.push("", recommendedSkillsContext);
|
|
1331
2241
|
}
|
|
2242
|
+
const patternsContext = buildConsolidatedPatternsContext();
|
|
2243
|
+
if (patternsContext) {
|
|
2244
|
+
parts.push("", patternsContext);
|
|
2245
|
+
}
|
|
1332
2246
|
const recentContext = buildRecentCompletedTaskContext(task, state);
|
|
1333
2247
|
if (recentContext) {
|
|
1334
2248
|
parts.push("", recentContext);
|
|
@@ -1339,6 +2253,21 @@ function buildTaskAssignmentDescription(task: TaskInfo, state: TeamState | null,
|
|
|
1339
2253
|
return parts.join("\n");
|
|
1340
2254
|
}
|
|
1341
2255
|
|
|
2256
|
+
function buildProjectDirectoryContext(projectDir?: string): string | null {
|
|
2257
|
+
if (!projectDir) {
|
|
2258
|
+
return null;
|
|
2259
|
+
}
|
|
2260
|
+
const workspaceRelativePath = buildTeamClawProjectWorkspacePath(projectDir);
|
|
2261
|
+
const absoluteProjectPath = path.join(resolveTeamClawProjectsDir(), projectDir).replace(/\\/gu, "/");
|
|
2262
|
+
return [
|
|
2263
|
+
"## Authoritative Project Paths",
|
|
2264
|
+
`- Workspace-relative project path: \`${workspaceRelativePath}/\``,
|
|
2265
|
+
`- Absolute project path: \`${absoluteProjectPath}/\``,
|
|
2266
|
+
"- If the task description mentions any other workspace path, treat it as stale text from an older layout.",
|
|
2267
|
+
"- Resolve project-local files (for example `ARCHITECTURE.md`, `README.md`, `package.json`, `data/...`) inside the authoritative project path above.",
|
|
2268
|
+
].join("\n");
|
|
2269
|
+
}
|
|
2270
|
+
|
|
1342
2271
|
function buildRepoTaskContext(repoInfo: RepoSyncInfo): string {
|
|
1343
2272
|
const lines = [
|
|
1344
2273
|
"## TeamClaw Git Collaboration",
|
|
@@ -1465,20 +2394,16 @@ async function cancelTaskExecution(
|
|
|
1465
2394
|
}
|
|
1466
2395
|
|
|
1467
2396
|
let cancelled = false;
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
});
|
|
1475
|
-
cancelled = res.ok;
|
|
1476
|
-
if (!res.ok) {
|
|
1477
|
-
deps.logger.warn(`Controller: worker cancel failed for ${taskId} on ${workerId} (${res.status})`);
|
|
1478
|
-
}
|
|
1479
|
-
} catch (err) {
|
|
1480
|
-
deps.logger.warn(`Controller: failed to cancel task ${taskId} on ${workerId}: ${String(err)}`);
|
|
2397
|
+
try {
|
|
2398
|
+
const res = await fetch(`${worker.url}/api/v1/tasks/${taskId}/cancel`, {
|
|
2399
|
+
method: "POST",
|
|
2400
|
+
});
|
|
2401
|
+
cancelled = res.ok;
|
|
2402
|
+
if (!res.ok) {
|
|
2403
|
+
deps.logger.warn(`Controller: worker cancel failed for ${taskId} on ${workerId} (${res.status})`);
|
|
1481
2404
|
}
|
|
2405
|
+
} catch (err) {
|
|
2406
|
+
deps.logger.warn(`Controller: failed to cancel task ${taskId} on ${workerId}: ${String(err)}`);
|
|
1482
2407
|
}
|
|
1483
2408
|
|
|
1484
2409
|
if (!cancelled) {
|
|
@@ -1511,11 +2436,308 @@ function workspaceRequestErrorStatus(err: unknown): number {
|
|
|
1511
2436
|
if (err && typeof err === "object" && "code" in err && (err as { code?: unknown }).code === "ENOENT") {
|
|
1512
2437
|
return 404;
|
|
1513
2438
|
}
|
|
1514
|
-
return 400;
|
|
2439
|
+
return 400;
|
|
2440
|
+
}
|
|
2441
|
+
|
|
2442
|
+
function workspaceRequestErrorMessage(err: unknown): string {
|
|
2443
|
+
return err instanceof Error ? err.message : "Workspace request failed";
|
|
2444
|
+
}
|
|
2445
|
+
|
|
2446
|
+
function getEffectiveStartupReadiness(
|
|
2447
|
+
deps: ControllerHttpDeps,
|
|
2448
|
+
state: TeamState | null,
|
|
2449
|
+
): StartupProvisioningReadiness | null {
|
|
2450
|
+
if (!deps.workerProvisioningManager?.isEnabled()) {
|
|
2451
|
+
return null;
|
|
2452
|
+
}
|
|
2453
|
+
const requiredRoles = deps.config.workerProvisioningRoles.length > 0
|
|
2454
|
+
? [...new Set(deps.config.workerProvisioningRoles)]
|
|
2455
|
+
: ["developer"];
|
|
2456
|
+
const readyWorkerIds = requiredRoles
|
|
2457
|
+
.map((role) => Object.values(state?.workers ?? {}).find((worker) =>
|
|
2458
|
+
worker.role === role && (worker.status === "idle" || worker.status === "busy")
|
|
2459
|
+
)?.id)
|
|
2460
|
+
.filter((workerId): workerId is string => Boolean(workerId));
|
|
2461
|
+
|
|
2462
|
+
if (readyWorkerIds.length === requiredRoles.length) {
|
|
2463
|
+
const recorded = state?.provisioning?.startupReadiness;
|
|
2464
|
+
return {
|
|
2465
|
+
status: "ready",
|
|
2466
|
+
startedAt: recorded?.startedAt ?? state?.createdAt ?? Date.now(),
|
|
2467
|
+
checkedAt: Date.now(),
|
|
2468
|
+
attempts: recorded?.attempts ?? 0,
|
|
2469
|
+
requiredRoles,
|
|
2470
|
+
readyWorkerIds,
|
|
2471
|
+
message: recorded?.status === "ready"
|
|
2472
|
+
? recorded.message
|
|
2473
|
+
: `Startup provisioning ready with ${readyWorkerIds.length} warm worker(s).`,
|
|
2474
|
+
};
|
|
2475
|
+
}
|
|
2476
|
+
|
|
2477
|
+
return state?.provisioning?.startupReadiness ?? {
|
|
2478
|
+
status: "checking",
|
|
2479
|
+
startedAt: state?.createdAt ?? Date.now(),
|
|
2480
|
+
checkedAt: Date.now(),
|
|
2481
|
+
attempts: 0,
|
|
2482
|
+
requiredRoles,
|
|
2483
|
+
readyWorkerIds,
|
|
2484
|
+
message: `Startup provisioning warm-up is still initializing for ${requiredRoles.join(", ")}.`,
|
|
2485
|
+
};
|
|
2486
|
+
}
|
|
2487
|
+
|
|
2488
|
+
function buildStartupReadinessMessage(readiness: StartupProvisioningReadiness): string {
|
|
2489
|
+
const requiredRoles = readiness.requiredRoles.length > 0 ? readiness.requiredRoles.join(", ") : "configured startup roles";
|
|
2490
|
+
if (readiness.status === "ready") {
|
|
2491
|
+
return readiness.message ?? "Startup provisioning is ready.";
|
|
2492
|
+
}
|
|
2493
|
+
if (readiness.status === "checking") {
|
|
2494
|
+
return readiness.message ?? `Startup provisioning is still warming workers for ${requiredRoles}.`;
|
|
2495
|
+
}
|
|
2496
|
+
return readiness.message ?? `Startup provisioning is degraded for ${requiredRoles}. Check provisioning logs and fix worker startup before retrying.`;
|
|
2497
|
+
}
|
|
2498
|
+
|
|
2499
|
+
function shouldBlockControllerUntilProvisioningReady(
|
|
2500
|
+
deps: ControllerHttpDeps,
|
|
2501
|
+
state: TeamState | null,
|
|
2502
|
+
): { blocked: boolean; readiness: StartupProvisioningReadiness | null } {
|
|
2503
|
+
const readiness = getEffectiveStartupReadiness(deps, state);
|
|
2504
|
+
if (!readiness) {
|
|
2505
|
+
return { blocked: false, readiness: null };
|
|
2506
|
+
}
|
|
2507
|
+
return {
|
|
2508
|
+
blocked: readiness.status !== "ready",
|
|
2509
|
+
readiness,
|
|
2510
|
+
};
|
|
2511
|
+
}
|
|
2512
|
+
|
|
2513
|
+
/**
|
|
2514
|
+
* Filter deliverables that don't belong to the current task's project directory.
|
|
2515
|
+
* This prevents cross-project contamination when the model references stale files
|
|
2516
|
+
* from previous sessions visible in the shared workspace.
|
|
2517
|
+
*/
|
|
2518
|
+
function filterStaleDeliverables(
|
|
2519
|
+
contract: WorkerTaskResultContract,
|
|
2520
|
+
taskProjectDir: string | undefined,
|
|
2521
|
+
): WorkerTaskResultContract {
|
|
2522
|
+
if (!taskProjectDir) return contract;
|
|
2523
|
+
// Normalize: "projects/foo-bar/" → "projects/foo-bar"
|
|
2524
|
+
const normalizedDir = taskProjectDir.replace(/\/$/u, "");
|
|
2525
|
+
const filtered = contract.deliverables.filter((d) => {
|
|
2526
|
+
const val = (d.value ?? "").replace(/\/$/u, "");
|
|
2527
|
+
if (!val) return true; // keep notes/commands without paths
|
|
2528
|
+
if (d.kind === "note" || d.kind === "command") return true;
|
|
2529
|
+
// Accept deliverables whose path contains the projectDir or is rooted in teamclaw/projects/{projectDir}
|
|
2530
|
+
if (val.includes(normalizedDir)) return true;
|
|
2531
|
+
// Accept relative paths that look like project-internal (no slashes or relative)
|
|
2532
|
+
if (!val.includes("/") || val.startsWith("./")) return true;
|
|
2533
|
+
// Reject paths from other project directories
|
|
2534
|
+
return false;
|
|
2535
|
+
});
|
|
2536
|
+
if (filtered.length === contract.deliverables.length) return contract;
|
|
2537
|
+
return { ...contract, deliverables: filtered };
|
|
2538
|
+
}
|
|
2539
|
+
|
|
2540
|
+
/**
|
|
2541
|
+
* Strategy 4: When text-based enrichment found no HTML file references,
|
|
2542
|
+
* scan the workspace filesystem for HTML files and create a web-app deliverable.
|
|
2543
|
+
* This handles workers that return abstract summaries without mentioning specific paths.
|
|
2544
|
+
*/
|
|
2545
|
+
function enrichWithFilesystemHtmlScan(
|
|
2546
|
+
contract: WorkerTaskResultContract,
|
|
2547
|
+
taskProjectDir?: string,
|
|
2548
|
+
): WorkerTaskResultContract | null {
|
|
2549
|
+
const existingWebApp = contract.deliverables.find((d) => d.artifactType === "web-app");
|
|
2550
|
+
if (existingWebApp) {
|
|
2551
|
+
const cwd = existingWebApp.previewCwd?.trim();
|
|
2552
|
+
if (cwd && cwd !== "." && cwd !== "./") {
|
|
2553
|
+
return null;
|
|
2554
|
+
}
|
|
2555
|
+
// Fall through — existing web-app has root previewCwd, try to improve it
|
|
2556
|
+
}
|
|
2557
|
+
|
|
2558
|
+
let workspaceDir: string;
|
|
2559
|
+
let openclawWorkspaceDir: string;
|
|
2560
|
+
try {
|
|
2561
|
+
workspaceDir = resolveTeamClawWorkspaceDir();
|
|
2562
|
+
openclawWorkspaceDir = resolveTeamClawAgentWorkspaceRootDir();
|
|
2563
|
+
} catch {
|
|
2564
|
+
return null;
|
|
2565
|
+
}
|
|
2566
|
+
|
|
2567
|
+
// Recursively scan the workspace for HTML files (up to 3 levels deep)
|
|
2568
|
+
const MAX_DEPTH = 3;
|
|
2569
|
+
const htmlCandidates: { dirPath: string; filename: string }[] = [];
|
|
2570
|
+
|
|
2571
|
+
function scanDir(dir: string, depth: number) {
|
|
2572
|
+
if (depth > MAX_DEPTH) return;
|
|
2573
|
+
let entries: fs.Dirent[];
|
|
2574
|
+
try {
|
|
2575
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
2576
|
+
} catch {
|
|
2577
|
+
return;
|
|
2578
|
+
}
|
|
2579
|
+
for (const entry of entries) {
|
|
2580
|
+
if (entry.name.startsWith(".") || entry.name === "node_modules" || entry.name === ".git") continue;
|
|
2581
|
+
const fullPath = path.join(dir, entry.name);
|
|
2582
|
+
if (entry.isDirectory()) {
|
|
2583
|
+
scanDir(fullPath, depth + 1);
|
|
2584
|
+
} else if (entry.isFile() && (entry.name.endsWith(".html") || entry.name.endsWith(".htm"))) {
|
|
2585
|
+
if (!entry.name.includes(".config.") && !entry.name.includes(".test.") && !entry.name.includes(".spec.")) {
|
|
2586
|
+
htmlCandidates.push({ dirPath: dir, filename: entry.name });
|
|
2587
|
+
}
|
|
2588
|
+
}
|
|
2589
|
+
}
|
|
2590
|
+
}
|
|
2591
|
+
|
|
2592
|
+
// Scope scan to the task's project directory when available to avoid
|
|
2593
|
+
// picking up stale HTML from unrelated projects.
|
|
2594
|
+
const scanRoot = taskProjectDir
|
|
2595
|
+
? path.join(workspaceDir, taskProjectDir)
|
|
2596
|
+
: workspaceDir;
|
|
2597
|
+
if (taskProjectDir && !fs.existsSync(scanRoot)) {
|
|
2598
|
+
return null;
|
|
2599
|
+
}
|
|
2600
|
+
scanDir(scanRoot, 0);
|
|
2601
|
+
|
|
2602
|
+
if (htmlCandidates.length === 0) {
|
|
2603
|
+
return null;
|
|
2604
|
+
}
|
|
2605
|
+
|
|
2606
|
+
// Use OpenClaw workspace root as base for relative paths (worker paths and
|
|
2607
|
+
// preview manager both resolve relative to the OpenClaw workspace, not the
|
|
2608
|
+
// TeamClaw subdirectory).
|
|
2609
|
+
const candidate = htmlCandidates[0];
|
|
2610
|
+
const relativeDir = path.relative(openclawWorkspaceDir, candidate.dirPath) || ".";
|
|
2611
|
+
const normalizedDir = relativeDir.replace(/\\/gu, "/");
|
|
2612
|
+
|
|
2613
|
+
// Avoid adding a duplicate if a directory deliverable for this path already exists
|
|
2614
|
+
const existingDir = contract.deliverables.find(
|
|
2615
|
+
(d) => d.kind === "directory" && d.value.replace(/\\/gu, "/").replace(/\/$/u, "") === normalizedDir,
|
|
2616
|
+
);
|
|
2617
|
+
if (existingDir && existingDir.artifactType === "web-app" && existingWebApp?.previewCwd?.trim() !== ".") {
|
|
2618
|
+
// Existing web-app already has a specific, non-root directory — leave it alone.
|
|
2619
|
+
return null;
|
|
2620
|
+
}
|
|
2621
|
+
|
|
2622
|
+
const newDeliverable: WorkerTaskResultDeliverable = {
|
|
2623
|
+
kind: "directory",
|
|
2624
|
+
value: normalizedDir,
|
|
2625
|
+
summary: `Web application at ${normalizedDir}`,
|
|
2626
|
+
artifactType: "web-app",
|
|
2627
|
+
previewCommand: "npx -y serve -l {PORT}",
|
|
2628
|
+
previewCwd: normalizedDir,
|
|
2629
|
+
previewReadyPath: "/",
|
|
2630
|
+
};
|
|
2631
|
+
|
|
2632
|
+
const newDeliverables = [...contract.deliverables];
|
|
2633
|
+
if (existingDir) {
|
|
2634
|
+
// Update existing directory deliverable with web-app fields
|
|
2635
|
+
const idx = newDeliverables.indexOf(existingDir);
|
|
2636
|
+
// Always take the filesystem scan's directory path, which is more specific
|
|
2637
|
+
newDeliverables[idx] = { ...existingDir, ...newDeliverable };
|
|
2638
|
+
} else {
|
|
2639
|
+
newDeliverables.push(newDeliverable);
|
|
2640
|
+
}
|
|
2641
|
+
|
|
2642
|
+
return { ...contract, deliverables: newDeliverables };
|
|
2643
|
+
}
|
|
2644
|
+
|
|
2645
|
+
const MEANINGFUL_PROJECT_CHANGE_EXTENSIONS = new Set([
|
|
2646
|
+
".js", ".jsx", ".ts", ".tsx", ".json", ".html", ".css", ".scss", ".md", ".txt", ".yml", ".yaml",
|
|
2647
|
+
]);
|
|
2648
|
+
|
|
2649
|
+
const IGNORED_PROJECT_CHANGE_DIRS = new Set([
|
|
2650
|
+
"node_modules", ".git", "dist", "build", ".next", ".cache", "coverage", "tmp", "temp",
|
|
2651
|
+
]);
|
|
2652
|
+
|
|
2653
|
+
const IGNORED_PROJECT_CHANGE_FILES = new Set([
|
|
2654
|
+
"package-lock.json", "pnpm-lock.yaml", "yarn.lock",
|
|
2655
|
+
]);
|
|
2656
|
+
|
|
2657
|
+
function taskRequiresMeaningfulProjectChangeGate(task: TaskInfo): boolean {
|
|
2658
|
+
if (task.assignedRole !== "developer" || !task.projectDir) {
|
|
2659
|
+
return false;
|
|
2660
|
+
}
|
|
2661
|
+
const text = `${task.title}\n${task.description}`.toLowerCase();
|
|
2662
|
+
if (/\b(qa|audit|review|verify|verification|validate|test|retest|confirm|check)\b/u.test(text)) {
|
|
2663
|
+
return false;
|
|
2664
|
+
}
|
|
2665
|
+
if (/(审计|审核|验证|复检|复查|确认|测试|检查)/u.test(text)) {
|
|
2666
|
+
return false;
|
|
2667
|
+
}
|
|
2668
|
+
return /\b(implement|build|fix|rework|update|add|enhanc|deliver|write|create)\b/u.test(text);
|
|
2669
|
+
}
|
|
2670
|
+
|
|
2671
|
+
function projectHasMeaningfulFileChanges(task: TaskInfo): boolean {
|
|
2672
|
+
if (!task.projectDir) {
|
|
2673
|
+
return false;
|
|
2674
|
+
}
|
|
2675
|
+
const projectRoot = path.join(resolveTeamClawProjectsDir(), task.projectDir);
|
|
2676
|
+
if (!fs.existsSync(projectRoot)) {
|
|
2677
|
+
return false;
|
|
2678
|
+
}
|
|
2679
|
+
const threshold = Math.max(task.startedAt ?? 0, task.createdAt ?? 0) - 1000;
|
|
2680
|
+
const stack = [projectRoot];
|
|
2681
|
+
while (stack.length > 0) {
|
|
2682
|
+
const currentDir = stack.pop()!;
|
|
2683
|
+
let entries: fs.Dirent[];
|
|
2684
|
+
try {
|
|
2685
|
+
entries = fs.readdirSync(currentDir, { withFileTypes: true });
|
|
2686
|
+
} catch {
|
|
2687
|
+
continue;
|
|
2688
|
+
}
|
|
2689
|
+
for (const entry of entries) {
|
|
2690
|
+
if (entry.isDirectory()) {
|
|
2691
|
+
if (!IGNORED_PROJECT_CHANGE_DIRS.has(entry.name)) {
|
|
2692
|
+
stack.push(path.join(currentDir, entry.name));
|
|
2693
|
+
}
|
|
2694
|
+
continue;
|
|
2695
|
+
}
|
|
2696
|
+
if (!entry.isFile()) {
|
|
2697
|
+
continue;
|
|
2698
|
+
}
|
|
2699
|
+
if (IGNORED_PROJECT_CHANGE_FILES.has(entry.name)) {
|
|
2700
|
+
continue;
|
|
2701
|
+
}
|
|
2702
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
2703
|
+
if (!MEANINGFUL_PROJECT_CHANGE_EXTENSIONS.has(ext)) {
|
|
2704
|
+
continue;
|
|
2705
|
+
}
|
|
2706
|
+
const fullPath = path.join(currentDir, entry.name);
|
|
2707
|
+
try {
|
|
2708
|
+
const stats = fs.statSync(fullPath);
|
|
2709
|
+
if (stats.mtimeMs >= threshold) {
|
|
2710
|
+
return true;
|
|
2711
|
+
}
|
|
2712
|
+
} catch {
|
|
2713
|
+
continue;
|
|
2714
|
+
}
|
|
2715
|
+
}
|
|
2716
|
+
}
|
|
2717
|
+
return false;
|
|
1515
2718
|
}
|
|
1516
2719
|
|
|
1517
|
-
function
|
|
1518
|
-
|
|
2720
|
+
function allowsNoChangeCompletion(
|
|
2721
|
+
task: TaskInfo,
|
|
2722
|
+
contract: WorkerTaskResultContract | undefined,
|
|
2723
|
+
resultText: string,
|
|
2724
|
+
): boolean {
|
|
2725
|
+
const taskText = `${task.title}\n${task.description}`.toLowerCase();
|
|
2726
|
+
const evidenceText = [
|
|
2727
|
+
contract?.summary ?? "",
|
|
2728
|
+
contract?.notes ?? "",
|
|
2729
|
+
...(contract?.keyPoints ?? []),
|
|
2730
|
+
resultText,
|
|
2731
|
+
].join("\n").toLowerCase();
|
|
2732
|
+
const hasVerificationEvidence =
|
|
2733
|
+
(contract?.deliverables ?? []).some((deliverable) => deliverable.kind === "command" || deliverable.kind === "note")
|
|
2734
|
+
|| /\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);
|
|
2735
|
+
const noChangeIsExpected =
|
|
2736
|
+
/\b(qa|audit|review|verify|verification|validate|test|retest|confirm|check)\b/u.test(taskText)
|
|
2737
|
+
|| /(审计|审核|验证|复检|复查|确认|测试|检查)/u.test(taskText)
|
|
2738
|
+
|| /\b(already fixed|already resolved|no further changes|no code changes|no file changes|no additional changes|verified existing fix)\b/u.test(evidenceText)
|
|
2739
|
+
|| /(已修复|已解决|无需改动|无需修改|没有额外修改|无需额外变更|只需验证)/u.test(evidenceText);
|
|
2740
|
+
return hasVerificationEvidence && noChangeIsExpected;
|
|
1519
2741
|
}
|
|
1520
2742
|
|
|
1521
2743
|
function applyTaskResult(
|
|
@@ -1536,6 +2758,19 @@ function applyTaskResult(
|
|
|
1536
2758
|
task.error = error;
|
|
1537
2759
|
task.completedAt = Date.now();
|
|
1538
2760
|
task.updatedAt = Date.now();
|
|
2761
|
+
// Re-enrich deliverables now that the full result text is available.
|
|
2762
|
+
if (!error && task.resultContract) {
|
|
2763
|
+
// Filter out stale deliverables from other projects first
|
|
2764
|
+
task.resultContract = filterStaleDeliverables(task.resultContract, task.projectDir);
|
|
2765
|
+
let enriched = enrichDeliverablesWithPreviewInference(task.resultContract, result);
|
|
2766
|
+
if (!enriched) {
|
|
2767
|
+
// Text-based enrichment failed — scan the workspace filesystem for HTML files.
|
|
2768
|
+
enriched = enrichWithFilesystemHtmlScan(task.resultContract, task.projectDir);
|
|
2769
|
+
}
|
|
2770
|
+
if (enriched) {
|
|
2771
|
+
task.resultContract = enriched;
|
|
2772
|
+
}
|
|
2773
|
+
}
|
|
1539
2774
|
completionEvent = appendTaskExecutionEvent(task, {
|
|
1540
2775
|
type: error ? "error" : "lifecycle",
|
|
1541
2776
|
phase: error ? "result_failed" : "result_completed",
|
|
@@ -1583,13 +2818,111 @@ function applyTaskResult(
|
|
|
1583
2818
|
logger.warn(
|
|
1584
2819
|
`Controller: failed to continue intake workflow after ${taskId}: ${String(err)}`,
|
|
1585
2820
|
);
|
|
2821
|
+
}).finally(() => {
|
|
2822
|
+
// After the follow-up run completes (or if there was none), check session completion.
|
|
2823
|
+
void checkAndGenerateReport(updatedTask, deps).catch(() => {});
|
|
1586
2824
|
});
|
|
2825
|
+
} else if (updatedTask.controllerSessionKey) {
|
|
2826
|
+
// Non-controller tasks or failed tasks — still check session completion.
|
|
2827
|
+
void checkAndGenerateReport(updatedTask, deps).catch(() => {});
|
|
1587
2828
|
}
|
|
1588
2829
|
}
|
|
1589
2830
|
|
|
1590
2831
|
return updatedTask;
|
|
1591
2832
|
}
|
|
1592
2833
|
|
|
2834
|
+
async function requestTaskClarification(params: {
|
|
2835
|
+
taskId: string;
|
|
2836
|
+
requestedBy: string;
|
|
2837
|
+
requestedByWorkerId?: string;
|
|
2838
|
+
requestedByRole?: RoleId;
|
|
2839
|
+
question: string;
|
|
2840
|
+
blockingReason: string;
|
|
2841
|
+
context?: string;
|
|
2842
|
+
questionSchema?: ClarificationRequest["questionSchema"];
|
|
2843
|
+
}, deps: ControllerHttpDeps): Promise<{
|
|
2844
|
+
status: "created" | "already-pending" | "conflict" | "missing-task";
|
|
2845
|
+
clarification?: ClarificationRequest;
|
|
2846
|
+
task?: TaskInfo;
|
|
2847
|
+
}> {
|
|
2848
|
+
const { getTeamState, updateTeamState, wsServer } = deps;
|
|
2849
|
+
const currentState = getTeamState();
|
|
2850
|
+
const currentTask = currentState?.tasks[params.taskId];
|
|
2851
|
+
if (!currentTask) {
|
|
2852
|
+
return { status: "missing-task" };
|
|
2853
|
+
}
|
|
2854
|
+
|
|
2855
|
+
if (currentTask.clarificationRequestId) {
|
|
2856
|
+
const existing = currentState?.clarifications[currentTask.clarificationRequestId];
|
|
2857
|
+
if (existing?.status === "pending") {
|
|
2858
|
+
return { status: "already-pending", clarification: existing, task: currentTask };
|
|
2859
|
+
}
|
|
2860
|
+
}
|
|
2861
|
+
|
|
2862
|
+
if (currentTask.status === "completed" || currentTask.status === "failed") {
|
|
2863
|
+
return { status: "conflict", task: currentTask };
|
|
2864
|
+
}
|
|
2865
|
+
|
|
2866
|
+
const previousWorkerId = currentTask.assignedWorkerId;
|
|
2867
|
+
const clarificationId = generateId();
|
|
2868
|
+
const now = Date.now();
|
|
2869
|
+
const clarification: ClarificationRequest = {
|
|
2870
|
+
id: clarificationId,
|
|
2871
|
+
taskId: params.taskId,
|
|
2872
|
+
requestedBy: params.requestedBy,
|
|
2873
|
+
requestedByWorkerId: params.requestedByWorkerId,
|
|
2874
|
+
requestedByRole: params.requestedByRole,
|
|
2875
|
+
question: params.question,
|
|
2876
|
+
questionSchema: params.questionSchema,
|
|
2877
|
+
blockingReason: params.blockingReason,
|
|
2878
|
+
context: params.context,
|
|
2879
|
+
status: "pending",
|
|
2880
|
+
createdAt: now,
|
|
2881
|
+
updatedAt: now,
|
|
2882
|
+
};
|
|
2883
|
+
|
|
2884
|
+
const state = updateTeamState((s) => {
|
|
2885
|
+
s.clarifications[clarificationId] = clarification;
|
|
2886
|
+
const task = s.tasks[params.taskId];
|
|
2887
|
+
if (!task) {
|
|
2888
|
+
return;
|
|
2889
|
+
}
|
|
2890
|
+
|
|
2891
|
+
const assignedWorkerId = task.assignedWorkerId;
|
|
2892
|
+
task.status = "blocked";
|
|
2893
|
+
task.progress = `Awaiting clarification: ${params.question}`;
|
|
2894
|
+
task.clarificationRequestId = clarificationId;
|
|
2895
|
+
task.assignedWorkerId = undefined;
|
|
2896
|
+
task.updatedAt = now;
|
|
2897
|
+
|
|
2898
|
+
if (assignedWorkerId && s.workers[assignedWorkerId]) {
|
|
2899
|
+
const assignedWorker = s.workers[assignedWorkerId];
|
|
2900
|
+
if (assignedWorker.status !== "offline") {
|
|
2901
|
+
assignedWorker.status = "idle";
|
|
2902
|
+
}
|
|
2903
|
+
assignedWorker.currentTaskId = undefined;
|
|
2904
|
+
}
|
|
2905
|
+
});
|
|
2906
|
+
|
|
2907
|
+
await cancelTaskExecution(params.taskId, previousWorkerId, "clarification request", deps);
|
|
2908
|
+
|
|
2909
|
+
const updatedTask = state.tasks[params.taskId];
|
|
2910
|
+
wsServer.broadcastUpdate({ type: "clarification:requested", data: clarification });
|
|
2911
|
+
if (updatedTask) {
|
|
2912
|
+
recordTaskExecutionEvent(params.taskId, {
|
|
2913
|
+
type: "lifecycle",
|
|
2914
|
+
phase: "clarification_requested",
|
|
2915
|
+
source: "controller",
|
|
2916
|
+
message: `Clarification requested: ${params.question}`,
|
|
2917
|
+
role: clarification.requestedByRole,
|
|
2918
|
+
workerId: clarification.requestedByWorkerId,
|
|
2919
|
+
}, deps);
|
|
2920
|
+
wsServer.broadcastUpdate({ type: "task:updated", data: serializeTask(updatedTask) });
|
|
2921
|
+
}
|
|
2922
|
+
|
|
2923
|
+
return { status: "created", clarification, task: updatedTask };
|
|
2924
|
+
}
|
|
2925
|
+
|
|
1593
2926
|
function ensureTaskResultContract(
|
|
1594
2927
|
taskId: string,
|
|
1595
2928
|
result: string,
|
|
@@ -1602,10 +2935,35 @@ function ensureTaskResultContract(
|
|
|
1602
2935
|
return undefined;
|
|
1603
2936
|
}
|
|
1604
2937
|
if (currentTask.resultContract) {
|
|
2938
|
+
// Worker submitted a structured contract — but it may be missing preview
|
|
2939
|
+
// fields (artifactType, previewCommand, etc.). Enrich deliverables
|
|
2940
|
+
// so the PreviewManager can auto-launch previews.
|
|
2941
|
+
let enriched = enrichDeliverablesWithPreviewInference(currentTask.resultContract, result);
|
|
2942
|
+
if (!enriched) {
|
|
2943
|
+
// Text-based enrichment failed — scan the workspace filesystem for HTML files.
|
|
2944
|
+
enriched = enrichWithFilesystemHtmlScan(currentTask.resultContract, currentTask.projectDir);
|
|
2945
|
+
}
|
|
2946
|
+
if (enriched) {
|
|
2947
|
+
deps.updateTeamState((teamState) => {
|
|
2948
|
+
const task = teamState.tasks[taskId];
|
|
2949
|
+
if (task) {
|
|
2950
|
+
task.resultContract = enriched;
|
|
2951
|
+
task.updatedAt = Date.now();
|
|
2952
|
+
}
|
|
2953
|
+
});
|
|
2954
|
+
return enriched;
|
|
2955
|
+
}
|
|
1605
2956
|
return currentTask.resultContract;
|
|
1606
2957
|
}
|
|
1607
2958
|
|
|
1608
|
-
|
|
2959
|
+
let contract = backfillWorkerTaskResultContract(currentTask, result, error);
|
|
2960
|
+
if (!error) {
|
|
2961
|
+
const enriched = enrichDeliverablesWithPreviewInference(contract, result)
|
|
2962
|
+
?? enrichWithFilesystemHtmlScan(contract, currentTask.projectDir);
|
|
2963
|
+
if (enriched) {
|
|
2964
|
+
contract = enriched;
|
|
2965
|
+
}
|
|
2966
|
+
}
|
|
1609
2967
|
deps.updateTeamState((teamState) => {
|
|
1610
2968
|
const task = teamState.tasks[taskId];
|
|
1611
2969
|
if (!task || task.resultContract) {
|
|
@@ -1670,9 +3028,78 @@ function buildResultContractSection(task: TaskInfo): string {
|
|
|
1670
3028
|
if (contract.notes) {
|
|
1671
3029
|
lines.push(`Notes: ${contract.notes}`);
|
|
1672
3030
|
}
|
|
3031
|
+
if (contract.discoveredPatterns && contract.discoveredPatterns.length > 0) {
|
|
3032
|
+
lines.push("Discovered patterns:");
|
|
3033
|
+
for (const pattern of contract.discoveredPatterns) {
|
|
3034
|
+
lines.push(`- ${pattern}`);
|
|
3035
|
+
}
|
|
3036
|
+
}
|
|
1673
3037
|
return lines.join("\n");
|
|
1674
3038
|
}
|
|
1675
3039
|
|
|
3040
|
+
const MAX_PATTERNS_FILE_BYTES = 64 * 1024;
|
|
3041
|
+
|
|
3042
|
+
function consolidateDiscoveredPatterns(
|
|
3043
|
+
contract: WorkerTaskResultContract,
|
|
3044
|
+
taskId: string,
|
|
3045
|
+
role: string | undefined,
|
|
3046
|
+
logger: PluginLogger,
|
|
3047
|
+
): void {
|
|
3048
|
+
const patterns = contract.discoveredPatterns;
|
|
3049
|
+
if (!patterns || patterns.length === 0) {
|
|
3050
|
+
return;
|
|
3051
|
+
}
|
|
3052
|
+
try {
|
|
3053
|
+
const workspaceDir = resolveTeamClawWorkspaceDir();
|
|
3054
|
+
const patternsFile = path.join(workspaceDir, "memory", "patterns.md");
|
|
3055
|
+
fs.mkdirSync(path.join(workspaceDir, "memory"), { recursive: true });
|
|
3056
|
+
|
|
3057
|
+
let currentSize = 0;
|
|
3058
|
+
try {
|
|
3059
|
+
currentSize = fs.statSync(patternsFile).size;
|
|
3060
|
+
} catch {
|
|
3061
|
+
// File doesn't exist yet; will be created by append.
|
|
3062
|
+
}
|
|
3063
|
+
if (currentSize > MAX_PATTERNS_FILE_BYTES) {
|
|
3064
|
+
logger.info(`Controller: patterns.md already ${currentSize} bytes, skipping consolidation for ${taskId}`);
|
|
3065
|
+
return;
|
|
3066
|
+
}
|
|
3067
|
+
|
|
3068
|
+
const roleLabel = role ?? "unknown";
|
|
3069
|
+
const timestamp = new Date().toISOString().slice(0, 10);
|
|
3070
|
+
const section = [
|
|
3071
|
+
"",
|
|
3072
|
+
`## ${timestamp} — ${taskId} (${roleLabel})`,
|
|
3073
|
+
...patterns.map((p) => `- ${p}`),
|
|
3074
|
+
].join("\n") + "\n";
|
|
3075
|
+
|
|
3076
|
+
fs.appendFileSync(patternsFile, section, "utf8");
|
|
3077
|
+
logger.info(`Controller: consolidated ${patterns.length} pattern(s) from ${taskId} into patterns.md`);
|
|
3078
|
+
} catch (err) {
|
|
3079
|
+
logger.warn(`Controller: failed to consolidate patterns for ${taskId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
3080
|
+
}
|
|
3081
|
+
}
|
|
3082
|
+
|
|
3083
|
+
function buildConsolidatedPatternsContext(): string {
|
|
3084
|
+
try {
|
|
3085
|
+
const workspaceDir = resolveTeamClawWorkspaceDir();
|
|
3086
|
+
const patternsFile = path.join(workspaceDir, "memory", "patterns.md");
|
|
3087
|
+
const content = fs.readFileSync(patternsFile, "utf8").trim();
|
|
3088
|
+
// Only inject if there are actual patterns (more than just the header)
|
|
3089
|
+
const lines = content.split("\n").filter((l: string) => l.startsWith("- "));
|
|
3090
|
+
if (lines.length === 0) {
|
|
3091
|
+
return "";
|
|
3092
|
+
}
|
|
3093
|
+
return [
|
|
3094
|
+
"## Discovered Codebase Patterns",
|
|
3095
|
+
"Previous workers discovered these reusable patterns. Apply them where relevant:",
|
|
3096
|
+
...lines,
|
|
3097
|
+
].join("\n");
|
|
3098
|
+
} catch {
|
|
3099
|
+
return "";
|
|
3100
|
+
}
|
|
3101
|
+
}
|
|
3102
|
+
|
|
1676
3103
|
function revertTaskAssignment(taskId: string, workerId: string, deps: ControllerHttpDeps): TaskInfo | undefined {
|
|
1677
3104
|
const { updateTeamState, wsServer } = deps;
|
|
1678
3105
|
let revertEvent: TaskExecutionEvent | undefined;
|
|
@@ -1702,6 +3129,7 @@ function revertTaskAssignment(taskId: string, workerId: string, deps: Controller
|
|
|
1702
3129
|
}
|
|
1703
3130
|
worker.currentTaskId = undefined;
|
|
1704
3131
|
}
|
|
3132
|
+
|
|
1705
3133
|
});
|
|
1706
3134
|
|
|
1707
3135
|
const updatedTask = state.tasks[taskId];
|
|
@@ -1723,17 +3151,6 @@ async function deliverMessageToWorker(
|
|
|
1723
3151
|
message: TeamMessage,
|
|
1724
3152
|
deps: ControllerHttpDeps,
|
|
1725
3153
|
): Promise<void> {
|
|
1726
|
-
const { localWorkerManager } = deps;
|
|
1727
|
-
|
|
1728
|
-
if (localWorkerManager?.isLocalWorkerId(worker.id)) {
|
|
1729
|
-
const queued = await localWorkerManager.queueMessage(worker.id, message);
|
|
1730
|
-
if (queued) {
|
|
1731
|
-
return;
|
|
1732
|
-
}
|
|
1733
|
-
|
|
1734
|
-
deps.logger.warn(`Controller: local message path unavailable for ${worker.id}, falling back to worker URL`);
|
|
1735
|
-
}
|
|
1736
|
-
|
|
1737
3154
|
const res = await fetch(`${worker.url}/api/v1/messages`, {
|
|
1738
3155
|
method: "POST",
|
|
1739
3156
|
headers: { "Content-Type": "application/json" },
|
|
@@ -1774,14 +3191,21 @@ async function dispatchTaskToWorker(
|
|
|
1774
3191
|
worker: WorkerInfo,
|
|
1775
3192
|
deps: ControllerHttpDeps,
|
|
1776
3193
|
): Promise<void> {
|
|
1777
|
-
const { getTeamState
|
|
3194
|
+
const { getTeamState } = deps;
|
|
1778
3195
|
const state = getTeamState();
|
|
1779
3196
|
const task = state?.tasks[taskId];
|
|
1780
3197
|
if (!task) {
|
|
1781
3198
|
throw new Error(`task ${taskId} not found`);
|
|
1782
3199
|
}
|
|
1783
3200
|
|
|
1784
|
-
|
|
3201
|
+
// Ensure project directory exists
|
|
3202
|
+
if (task.projectDir) {
|
|
3203
|
+
const projectPath = path.join(resolveTeamClawProjectsDir(), task.projectDir);
|
|
3204
|
+
try { fs.mkdirSync(projectPath, { recursive: true }); } catch { /* best-effort */ }
|
|
3205
|
+
}
|
|
3206
|
+
|
|
3207
|
+
const sharedWorkspace = deps.workerProvisioningManager?.isSharedWorkspaceWorker(worker.id)
|
|
3208
|
+
|| false;
|
|
1785
3209
|
const repoState = await refreshControllerRepoState(deps);
|
|
1786
3210
|
const repoInfo = buildRepoSyncInfo(repoState, sharedWorkspace);
|
|
1787
3211
|
const description = buildTaskAssignmentDescription(task, state ?? null, repoInfo);
|
|
@@ -1793,20 +3217,12 @@ async function dispatchTaskToWorker(
|
|
|
1793
3217
|
description,
|
|
1794
3218
|
priority: task.priority,
|
|
1795
3219
|
recommendedSkills,
|
|
3220
|
+
projectDir: task.projectDir,
|
|
1796
3221
|
executionSessionKey: executionIdentity.executionSessionKey,
|
|
1797
3222
|
executionIdempotencyKey: executionIdentity.executionIdempotencyKey,
|
|
1798
3223
|
repo: repoInfo,
|
|
1799
3224
|
};
|
|
1800
3225
|
|
|
1801
|
-
if (localWorkerManager?.isLocalWorkerId(worker.id)) {
|
|
1802
|
-
const accepted = await localWorkerManager.dispatchTask(worker.id, assignment);
|
|
1803
|
-
if (accepted) {
|
|
1804
|
-
return;
|
|
1805
|
-
}
|
|
1806
|
-
|
|
1807
|
-
deps.logger.warn(`Controller: local dispatch path unavailable for ${worker.id}, falling back to worker URL`);
|
|
1808
|
-
}
|
|
1809
|
-
|
|
1810
3226
|
const res = await fetch(`${worker.url}/api/v1/tasks/assign`, {
|
|
1811
3227
|
method: "POST",
|
|
1812
3228
|
headers: { "Content-Type": "application/json" },
|
|
@@ -1903,12 +3319,21 @@ async function autoAssignPendingTasks(
|
|
|
1903
3319
|
break;
|
|
1904
3320
|
}
|
|
1905
3321
|
|
|
1906
|
-
const
|
|
3322
|
+
const candidateAssignments = taskRouter
|
|
1907
3323
|
.autoAssignPendingTasks(state.tasks, state.workers)
|
|
1908
|
-
.filter(({ worker }) => !
|
|
1909
|
-
|
|
3324
|
+
.filter(({ task, worker }) => !attemptedPairs.has(`${task.id}:${worker.id}`));
|
|
3325
|
+
|
|
3326
|
+
const nextAssignment = (
|
|
3327
|
+
(preferredWorkerId
|
|
3328
|
+
? candidateAssignments.find(({ worker }) => worker.id === preferredWorkerId)
|
|
3329
|
+
: undefined)
|
|
3330
|
+
?? candidateAssignments[0]
|
|
3331
|
+
);
|
|
1910
3332
|
|
|
1911
3333
|
if (!nextAssignment) {
|
|
3334
|
+
scheduleProvisioningReconcile(deps, preferredWorkerId
|
|
3335
|
+
? `auto-assign-wait:${preferredWorkerId}`
|
|
3336
|
+
: "auto-assign-wait");
|
|
1912
3337
|
break;
|
|
1913
3338
|
}
|
|
1914
3339
|
|
|
@@ -1964,6 +3389,19 @@ export function createControllerHttpServer(deps: ControllerHttpDeps): http.Serve
|
|
|
1964
3389
|
// Attach WebSocket
|
|
1965
3390
|
wsServer.attach(server);
|
|
1966
3391
|
|
|
3392
|
+
setTimeout(() => {
|
|
3393
|
+
const state = deps.getTeamState();
|
|
3394
|
+
const pendingCount = state
|
|
3395
|
+
? Object.values(state.tasks).filter((t) => t.status === "pending" && !t.assignedWorkerId).length
|
|
3396
|
+
: 0;
|
|
3397
|
+
if (pendingCount > 0) {
|
|
3398
|
+
logger.info(`Controller: startup reconciliation — ${pendingCount} pending tasks, triggering auto-assign`);
|
|
3399
|
+
void autoAssignPendingTasks(deps).catch((err) => {
|
|
3400
|
+
logger.warn(`Controller: startup auto-assign failed: ${String(err)}`);
|
|
3401
|
+
});
|
|
3402
|
+
}
|
|
3403
|
+
}, 2000);
|
|
3404
|
+
|
|
1967
3405
|
return server;
|
|
1968
3406
|
}
|
|
1969
3407
|
|
|
@@ -1975,6 +3413,7 @@ async function handleRequest(
|
|
|
1975
3413
|
): Promise<void> {
|
|
1976
3414
|
const { config, logger, getTeamState, updateTeamState, taskRouter, messageRouter, wsServer } = deps;
|
|
1977
3415
|
const requestUrl = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
3416
|
+
const currentState = getTeamState();
|
|
1978
3417
|
|
|
1979
3418
|
// ==================== Web UI ====================
|
|
1980
3419
|
if (req.method === "GET" && pathname === "/") {
|
|
@@ -1997,6 +3436,12 @@ async function handleRequest(
|
|
|
1997
3436
|
serveStaticFile(res, path.join(uiPath, file), "text/css; charset=utf-8");
|
|
1998
3437
|
} else if (file.endsWith(".js")) {
|
|
1999
3438
|
serveStaticFile(res, path.join(uiPath, file), "application/javascript; charset=utf-8");
|
|
3439
|
+
} else if (file.endsWith(".png")) {
|
|
3440
|
+
serveStaticFile(res, path.join(uiPath, file), "image/png");
|
|
3441
|
+
} else if (file.endsWith(".svg")) {
|
|
3442
|
+
serveStaticFile(res, path.join(uiPath, file), "image/svg+xml; charset=utf-8");
|
|
3443
|
+
} else if (file.endsWith(".ico")) {
|
|
3444
|
+
serveStaticFile(res, path.join(uiPath, file), "image/x-icon");
|
|
2000
3445
|
} else {
|
|
2001
3446
|
serveStaticFile(res, path.join(uiPath, file), "application/octet-stream");
|
|
2002
3447
|
}
|
|
@@ -2007,7 +3452,24 @@ async function handleRequest(
|
|
|
2007
3452
|
|
|
2008
3453
|
if (req.method === "GET" && pathname === "/api/v1/workspace/tree") {
|
|
2009
3454
|
try {
|
|
2010
|
-
|
|
3455
|
+
// depth=N limits tree expansion (default 1 for lazy loading)
|
|
3456
|
+
const depthParam = requestUrl.searchParams.get("depth");
|
|
3457
|
+
const maxDepth = depthParam !== null ? Math.max(0, Math.min(parseInt(depthParam, 10) || 1, 8)) : 1;
|
|
3458
|
+
sendJson(res, 200, await listWorkspaceTree(maxDepth));
|
|
3459
|
+
} catch (err) {
|
|
3460
|
+
sendError(res, workspaceRequestErrorStatus(err), workspaceRequestErrorMessage(err));
|
|
3461
|
+
}
|
|
3462
|
+
return;
|
|
3463
|
+
}
|
|
3464
|
+
|
|
3465
|
+
if (req.method === "GET" && pathname === "/api/v1/workspace/subtree") {
|
|
3466
|
+
const dirPath = requestUrl.searchParams.get("path") ?? "";
|
|
3467
|
+
if (!dirPath) {
|
|
3468
|
+
sendError(res, 400, "path is required");
|
|
3469
|
+
return;
|
|
3470
|
+
}
|
|
3471
|
+
try {
|
|
3472
|
+
sendJson(res, 200, { entries: await listWorkspaceSubtree(dirPath) });
|
|
2011
3473
|
} catch (err) {
|
|
2012
3474
|
sendError(res, workspaceRequestErrorStatus(err), workspaceRequestErrorMessage(err));
|
|
2013
3475
|
}
|
|
@@ -2102,11 +3564,6 @@ async function handleRequest(
|
|
|
2102
3564
|
// DELETE /api/v1/workers/:id
|
|
2103
3565
|
if (req.method === "DELETE" && pathname.match(/^\/api\/v1\/workers\/[^/]+$/)) {
|
|
2104
3566
|
const workerId = pathname.split("/").pop()!;
|
|
2105
|
-
if (deps.localWorkerManager?.isLocalWorkerId(workerId)) {
|
|
2106
|
-
sendError(res, 400, "Local workers are managed by controller config");
|
|
2107
|
-
return;
|
|
2108
|
-
}
|
|
2109
|
-
|
|
2110
3567
|
if (deps.workerProvisioningManager?.hasManagedWorker(workerId)) {
|
|
2111
3568
|
await deps.workerProvisioningManager.onWorkerRemoved(workerId, "worker delete requested");
|
|
2112
3569
|
}
|
|
@@ -2203,6 +3660,13 @@ async function handleRequest(
|
|
|
2203
3660
|
sendError(res, 400, "title is required");
|
|
2204
3661
|
return;
|
|
2205
3662
|
}
|
|
3663
|
+
if (createdBy === "controller") {
|
|
3664
|
+
const readinessGate = shouldBlockControllerUntilProvisioningReady(deps, currentState);
|
|
3665
|
+
if (readinessGate.blocked && readinessGate.readiness) {
|
|
3666
|
+
sendError(res, 503, buildStartupReadinessMessage(readinessGate.readiness));
|
|
3667
|
+
return;
|
|
3668
|
+
}
|
|
3669
|
+
}
|
|
2206
3670
|
if (createdBy === "controller" && shouldBlockControllerWithoutWorkers(deps.config, getTeamState())) {
|
|
2207
3671
|
sendError(res, 409, buildControllerNoWorkersMessage());
|
|
2208
3672
|
return;
|
|
@@ -2212,6 +3676,17 @@ async function handleRequest(
|
|
|
2212
3676
|
const now = Date.now();
|
|
2213
3677
|
const repoState = await refreshControllerRepoState(deps);
|
|
2214
3678
|
|
|
3679
|
+
// Resolve project directory: inherit from parent run, or generate from title
|
|
3680
|
+
let projectDir: string | undefined;
|
|
3681
|
+
if (controllerSessionKey) {
|
|
3682
|
+
const runId = findLatestControllerRunIdForSession(controllerSessionKey, getTeamState(), { preferActive: true });
|
|
3683
|
+
const parentRun = runId ? getTeamState()?.controllerRuns[runId] : undefined;
|
|
3684
|
+
projectDir = parentRun?.projectDir;
|
|
3685
|
+
}
|
|
3686
|
+
if (!projectDir) {
|
|
3687
|
+
projectDir = deriveProjectSlug(title);
|
|
3688
|
+
}
|
|
3689
|
+
|
|
2215
3690
|
const task: TaskInfo = {
|
|
2216
3691
|
id: taskId,
|
|
2217
3692
|
title,
|
|
@@ -2222,6 +3697,7 @@ async function handleRequest(
|
|
|
2222
3697
|
createdBy,
|
|
2223
3698
|
recommendedSkills: recommendedSkills.length > 0 ? recommendedSkills : undefined,
|
|
2224
3699
|
controllerSessionKey,
|
|
3700
|
+
projectDir,
|
|
2225
3701
|
createdAt: now,
|
|
2226
3702
|
updatedAt: now,
|
|
2227
3703
|
};
|
|
@@ -2536,8 +4012,18 @@ async function handleRequest(
|
|
|
2536
4012
|
if (!task) {
|
|
2537
4013
|
return;
|
|
2538
4014
|
}
|
|
2539
|
-
task.resultContract = contract;
|
|
4015
|
+
task.resultContract = filterStaleDeliverables(contract, task.projectDir);
|
|
2540
4016
|
task.updatedAt = Date.now();
|
|
4017
|
+
// Enrich deliverables with preview inference so PreviewManager can auto-launch.
|
|
4018
|
+
// Workers typically don't include artifactType/previewCommand in their contracts.
|
|
4019
|
+
const existingResult = task.result ?? "";
|
|
4020
|
+
let enriched = enrichDeliverablesWithPreviewInference(task.resultContract, existingResult);
|
|
4021
|
+
if (!enriched) {
|
|
4022
|
+
enriched = enrichWithFilesystemHtmlScan(contract, task.projectDir);
|
|
4023
|
+
}
|
|
4024
|
+
if (enriched) {
|
|
4025
|
+
task.resultContract = enriched;
|
|
4026
|
+
}
|
|
2541
4027
|
});
|
|
2542
4028
|
recordTaskExecutionEvent(taskId, {
|
|
2543
4029
|
type: "output",
|
|
@@ -2548,6 +4034,7 @@ async function handleRequest(
|
|
|
2548
4034
|
workerId,
|
|
2549
4035
|
role: currentTask.assignedRole,
|
|
2550
4036
|
}, deps);
|
|
4037
|
+
consolidateDiscoveredPatterns(contract, taskId, currentTask.assignedRole, logger);
|
|
2551
4038
|
sendJson(res, 201, { task: serializeTask(state.tasks[taskId]) });
|
|
2552
4039
|
return;
|
|
2553
4040
|
}
|
|
@@ -2557,7 +4044,7 @@ async function handleRequest(
|
|
|
2557
4044
|
const taskId = pathname.split("/")[4]!;
|
|
2558
4045
|
const body = await parseJsonBody(req);
|
|
2559
4046
|
const result = typeof body.result === "string" ? body.result : "";
|
|
2560
|
-
|
|
4047
|
+
let error = typeof body.error === "string" ? body.error : undefined;
|
|
2561
4048
|
const workerId = typeof body.workerId === "string" ? body.workerId : undefined;
|
|
2562
4049
|
const currentTask = getTeamState()?.tasks[taskId];
|
|
2563
4050
|
if (!currentTask) {
|
|
@@ -2585,14 +4072,72 @@ async function handleRequest(
|
|
|
2585
4072
|
phase: "result_contract_recorded",
|
|
2586
4073
|
source: "worker",
|
|
2587
4074
|
status: error ? "failed" : "running",
|
|
2588
|
-
|
|
4075
|
+
message: submittedContract.summary,
|
|
4076
|
+
workerId,
|
|
4077
|
+
role: currentTask.assignedRole,
|
|
4078
|
+
}, deps);
|
|
4079
|
+
}
|
|
4080
|
+
|
|
4081
|
+
if (!error && submittedContract?.outcome === "blocked") {
|
|
4082
|
+
const requested = await requestTaskClarification({
|
|
4083
|
+
taskId,
|
|
4084
|
+
requestedBy: workerId ?? "worker",
|
|
4085
|
+
requestedByWorkerId: workerId,
|
|
4086
|
+
requestedByRole: currentTask.assignedRole,
|
|
4087
|
+
question: submittedContract.questions[0]
|
|
4088
|
+
?? "This task is blocked and needs a human decision before work can continue. What should TeamClaw do next?",
|
|
4089
|
+
blockingReason: submittedContract.blockers[0] ?? submittedContract.summary,
|
|
4090
|
+
context: [
|
|
4091
|
+
submittedContract.notes,
|
|
4092
|
+
result.trim(),
|
|
4093
|
+
submittedContract.keyPoints.length > 0
|
|
4094
|
+
? `Worker-provided commands/details:\n${submittedContract.keyPoints.join("\n")}`
|
|
4095
|
+
: "",
|
|
4096
|
+
].filter(Boolean).join("\n\n"),
|
|
4097
|
+
}, deps);
|
|
4098
|
+
if (requested.status === "missing-task") {
|
|
4099
|
+
sendError(res, 404, "Task not found");
|
|
4100
|
+
return;
|
|
4101
|
+
}
|
|
4102
|
+
if (requested.status === "conflict") {
|
|
4103
|
+
sendError(res, 409, "Cannot request clarification for a completed task");
|
|
4104
|
+
return;
|
|
4105
|
+
}
|
|
4106
|
+
sendJson(res, requested.status === "already-pending" ? 200 : 201, {
|
|
4107
|
+
status: requested.status,
|
|
4108
|
+
clarification: requested.clarification,
|
|
4109
|
+
task: serializeTask(requested.task),
|
|
4110
|
+
});
|
|
4111
|
+
return;
|
|
4112
|
+
}
|
|
4113
|
+
|
|
4114
|
+
const gatedTask = getTeamState()?.tasks[taskId];
|
|
4115
|
+
if (
|
|
4116
|
+
!error
|
|
4117
|
+
&& gatedTask
|
|
4118
|
+
&& taskRequiresMeaningfulProjectChangeGate(gatedTask)
|
|
4119
|
+
&& !projectHasMeaningfulFileChanges(gatedTask)
|
|
4120
|
+
&& !allowsNoChangeCompletion(gatedTask, submittedContract, result)
|
|
4121
|
+
) {
|
|
4122
|
+
error = "Task reported completion but no meaningful project file changes were detected in the assigned project directory.";
|
|
4123
|
+
recordTaskExecutionEvent(taskId, {
|
|
4124
|
+
type: "warning",
|
|
4125
|
+
phase: "completion_gate",
|
|
4126
|
+
source: "controller",
|
|
4127
|
+
status: "failed",
|
|
4128
|
+
message: error,
|
|
2589
4129
|
workerId,
|
|
2590
|
-
role:
|
|
4130
|
+
role: gatedTask.assignedRole,
|
|
2591
4131
|
}, deps);
|
|
2592
4132
|
}
|
|
2593
4133
|
|
|
2594
4134
|
const updatedTask = applyTaskResult(taskId, result, error, deps);
|
|
2595
4135
|
ensureTaskResultContract(taskId, result, error, deps);
|
|
4136
|
+
if (!error) {
|
|
4137
|
+
deps.previewManager?.syncTaskPreviews(taskId).catch((err) => {
|
|
4138
|
+
logger.warn(`Controller: failed to sync previews for ${taskId}: ${String(err)}`);
|
|
4139
|
+
});
|
|
4140
|
+
}
|
|
2596
4141
|
if (!workerId || workerId !== previousWorkerId) {
|
|
2597
4142
|
await cancelTaskExecution(taskId, previousWorkerId, "manual result submission", deps);
|
|
2598
4143
|
}
|
|
@@ -2644,6 +4189,14 @@ async function handleRequest(
|
|
|
2644
4189
|
return;
|
|
2645
4190
|
}
|
|
2646
4191
|
|
|
4192
|
+
// When a result contract is backfilled, the task is effectively complete —
|
|
4193
|
+
// trigger preview sync so that any web-app deliverables get previewed.
|
|
4194
|
+
if (recorded.event?.phase === "result_contract_backfilled") {
|
|
4195
|
+
deps.previewManager?.syncTaskPreviews(taskId).catch((err) => {
|
|
4196
|
+
logger.warn(`Controller: failed to sync previews for ${taskId}: ${String(err)}`);
|
|
4197
|
+
});
|
|
4198
|
+
}
|
|
4199
|
+
|
|
2647
4200
|
sendJson(res, 201, {
|
|
2648
4201
|
task: serializeTask(recorded.task),
|
|
2649
4202
|
execution: buildTaskExecutionSummary(recorded.task.execution),
|
|
@@ -2674,6 +4227,19 @@ async function handleRequest(
|
|
|
2674
4227
|
|
|
2675
4228
|
const updatedRun = updateControllerRun(runId, deps, (run) => {
|
|
2676
4229
|
run.manifest = manifest;
|
|
4230
|
+
|
|
4231
|
+
if (run.projectDir) {
|
|
4232
|
+
const state = deps.getTeamState();
|
|
4233
|
+
if (state) {
|
|
4234
|
+
for (const taskId of run.createdTaskIds) {
|
|
4235
|
+
const task = state.tasks[taskId];
|
|
4236
|
+
if (task && !task.projectDir) {
|
|
4237
|
+
task.projectDir = run.projectDir;
|
|
4238
|
+
}
|
|
4239
|
+
}
|
|
4240
|
+
}
|
|
4241
|
+
}
|
|
4242
|
+
|
|
2677
4243
|
appendControllerRunEvent(run, {
|
|
2678
4244
|
type: "output",
|
|
2679
4245
|
phase: "manifest_recorded",
|
|
@@ -2689,6 +4255,18 @@ async function handleRequest(
|
|
|
2689
4255
|
return;
|
|
2690
4256
|
}
|
|
2691
4257
|
|
|
4258
|
+
if (manifest.requirementFullyComplete) {
|
|
4259
|
+
logger.info(`Controller: requirement fully complete for session ${sessionKey} (run ${runId})`);
|
|
4260
|
+
wsServer.broadcastUpdate({
|
|
4261
|
+
type: "requirement:complete",
|
|
4262
|
+
data: {
|
|
4263
|
+
runId,
|
|
4264
|
+
sessionKey,
|
|
4265
|
+
requirementSummary: manifest.requirementSummary,
|
|
4266
|
+
},
|
|
4267
|
+
});
|
|
4268
|
+
}
|
|
4269
|
+
|
|
2692
4270
|
sendJson(res, 201, {
|
|
2693
4271
|
controllerRun: serializeControllerRun(updatedRun),
|
|
2694
4272
|
manifest,
|
|
@@ -2698,7 +4276,7 @@ async function handleRequest(
|
|
|
2698
4276
|
|
|
2699
4277
|
// GET /api/v1/controller/runs
|
|
2700
4278
|
if (req.method === "GET" && pathname === "/api/v1/controller/runs") {
|
|
2701
|
-
const state =
|
|
4279
|
+
const state = reconcileControllerClarifications(deps);
|
|
2702
4280
|
const controllerRuns = state
|
|
2703
4281
|
? Object.values(state.controllerRuns)
|
|
2704
4282
|
.sort((left, right) => right.updatedAt - left.updatedAt)
|
|
@@ -2710,6 +4288,11 @@ async function handleRequest(
|
|
|
2710
4288
|
|
|
2711
4289
|
// POST /api/v1/controller/intake
|
|
2712
4290
|
if (req.method === "POST" && pathname === "/api/v1/controller/intake") {
|
|
4291
|
+
const readinessGate = shouldBlockControllerUntilProvisioningReady(deps, currentState);
|
|
4292
|
+
if (readinessGate.blocked && readinessGate.readiness) {
|
|
4293
|
+
sendError(res, 503, buildStartupReadinessMessage(readinessGate.readiness));
|
|
4294
|
+
return;
|
|
4295
|
+
}
|
|
2713
4296
|
const body = await parseJsonBody(req);
|
|
2714
4297
|
const message = typeof body.message === "string" ? body.message.trim() : "";
|
|
2715
4298
|
if (!message) {
|
|
@@ -2858,95 +4441,49 @@ async function handleRequest(
|
|
|
2858
4441
|
const question = typeof body.question === "string" ? body.question.trim() : "";
|
|
2859
4442
|
const blockingReason = typeof body.blockingReason === "string" ? body.blockingReason.trim() : "";
|
|
2860
4443
|
const context = typeof body.context === "string" && body.context.trim() ? body.context.trim() : undefined;
|
|
4444
|
+
const questionSchema = normalizeClarificationQuestionSchema(body.questionSchema);
|
|
2861
4445
|
|
|
2862
4446
|
if (!taskId || !question || !blockingReason) {
|
|
2863
4447
|
sendError(res, 400, "taskId, question, and blockingReason are required");
|
|
2864
4448
|
return;
|
|
2865
4449
|
}
|
|
2866
4450
|
|
|
2867
|
-
const
|
|
2868
|
-
const currentTask = currentState?.tasks[taskId];
|
|
2869
|
-
if (!currentTask) {
|
|
2870
|
-
sendError(res, 404, "Task not found");
|
|
2871
|
-
return;
|
|
2872
|
-
}
|
|
2873
|
-
|
|
2874
|
-
if (currentTask.clarificationRequestId) {
|
|
2875
|
-
const existing = currentState?.clarifications[currentTask.clarificationRequestId];
|
|
2876
|
-
if (existing?.status === "pending") {
|
|
2877
|
-
sendJson(res, 200, { clarification: existing, task: currentTask, status: "already-pending" });
|
|
2878
|
-
return;
|
|
2879
|
-
}
|
|
2880
|
-
}
|
|
2881
|
-
|
|
2882
|
-
if (currentTask.status === "completed" || currentTask.status === "failed") {
|
|
2883
|
-
sendError(res, 409, "Cannot request clarification for a completed task");
|
|
2884
|
-
return;
|
|
2885
|
-
}
|
|
2886
|
-
|
|
2887
|
-
const previousWorkerId = currentTask.assignedWorkerId;
|
|
2888
|
-
|
|
2889
|
-
const clarificationId = generateId();
|
|
2890
|
-
const now = Date.now();
|
|
2891
|
-
const clarification: ClarificationRequest = {
|
|
2892
|
-
id: clarificationId,
|
|
4451
|
+
const requested = await requestTaskClarification({
|
|
2893
4452
|
taskId,
|
|
2894
4453
|
requestedBy,
|
|
2895
4454
|
requestedByWorkerId,
|
|
2896
4455
|
requestedByRole,
|
|
2897
4456
|
question,
|
|
4457
|
+
questionSchema,
|
|
2898
4458
|
blockingReason,
|
|
2899
4459
|
context,
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
|
|
2912
|
-
|
|
2913
|
-
|
|
2914
|
-
|
|
2915
|
-
|
|
2916
|
-
task.assignedWorkerId = undefined;
|
|
2917
|
-
task.updatedAt = now;
|
|
2918
|
-
|
|
2919
|
-
if (assignedWorkerId && s.workers[assignedWorkerId]) {
|
|
2920
|
-
const assignedWorker = s.workers[assignedWorkerId];
|
|
2921
|
-
if (assignedWorker.status !== "offline") {
|
|
2922
|
-
assignedWorker.status = "idle";
|
|
2923
|
-
}
|
|
2924
|
-
assignedWorker.currentTaskId = undefined;
|
|
2925
|
-
}
|
|
2926
|
-
});
|
|
2927
|
-
|
|
2928
|
-
await cancelTaskExecution(taskId, previousWorkerId, "clarification request", deps);
|
|
2929
|
-
|
|
2930
|
-
const updatedTask = state.tasks[taskId];
|
|
2931
|
-
wsServer.broadcastUpdate({ type: "clarification:requested", data: clarification });
|
|
2932
|
-
if (updatedTask) {
|
|
2933
|
-
recordTaskExecutionEvent(taskId, {
|
|
2934
|
-
type: "lifecycle",
|
|
2935
|
-
phase: "clarification_requested",
|
|
2936
|
-
source: "controller",
|
|
2937
|
-
message: `Clarification requested: ${question}`,
|
|
2938
|
-
role: clarification.requestedByRole,
|
|
2939
|
-
workerId: clarification.requestedByWorkerId,
|
|
2940
|
-
}, deps);
|
|
2941
|
-
wsServer.broadcastUpdate({ type: "task:updated", data: serializeTask(updatedTask) });
|
|
4460
|
+
}, deps);
|
|
4461
|
+
if (requested.status === "missing-task") {
|
|
4462
|
+
sendError(res, 404, "Task not found");
|
|
4463
|
+
return;
|
|
4464
|
+
}
|
|
4465
|
+
if (requested.status === "conflict") {
|
|
4466
|
+
sendError(res, 409, "Cannot request clarification for a completed task");
|
|
4467
|
+
return;
|
|
4468
|
+
}
|
|
4469
|
+
if (requested.status === "already-pending") {
|
|
4470
|
+
sendJson(res, 200, {
|
|
4471
|
+
clarification: requested.clarification,
|
|
4472
|
+
task: serializeTask(requested.task),
|
|
4473
|
+
status: "already-pending",
|
|
4474
|
+
});
|
|
4475
|
+
return;
|
|
2942
4476
|
}
|
|
2943
|
-
sendJson(res, 201, {
|
|
4477
|
+
sendJson(res, 201, {
|
|
4478
|
+
clarification: requested.clarification,
|
|
4479
|
+
task: serializeTask(requested.task),
|
|
4480
|
+
});
|
|
2944
4481
|
return;
|
|
2945
4482
|
}
|
|
2946
4483
|
|
|
2947
4484
|
// GET /api/v1/clarifications
|
|
2948
4485
|
if (req.method === "GET" && pathname === "/api/v1/clarifications") {
|
|
2949
|
-
const state =
|
|
4486
|
+
const state = reconcileControllerClarifications(deps);
|
|
2950
4487
|
const clarifications = state
|
|
2951
4488
|
? Object.values(state.clarifications).sort((left, right) => right.createdAt - left.createdAt)
|
|
2952
4489
|
: [];
|
|
@@ -2961,16 +4498,20 @@ async function handleRequest(
|
|
|
2961
4498
|
if (req.method === "POST" && pathname.match(/^\/api\/v1\/clarifications\/[^/]+\/answer$/)) {
|
|
2962
4499
|
const clarificationId = pathname.split("/")[4]!;
|
|
2963
4500
|
const body = await parseJsonBody(req);
|
|
2964
|
-
const
|
|
4501
|
+
const answerValue = typeof body.answerValue === "string" ? body.answerValue.trim() : undefined;
|
|
4502
|
+
const answerValues = Array.isArray(body.answerValues)
|
|
4503
|
+
? body.answerValues.map((entry) => String(entry || "").trim()).filter(Boolean)
|
|
4504
|
+
: undefined;
|
|
4505
|
+
const answerNumber = typeof body.answerNumber === "number" && Number.isFinite(body.answerNumber)
|
|
4506
|
+
? body.answerNumber
|
|
4507
|
+
: undefined;
|
|
4508
|
+
const answerComment = typeof body.answerComment === "string" && body.answerComment.trim()
|
|
4509
|
+
? body.answerComment.trim()
|
|
4510
|
+
: undefined;
|
|
2965
4511
|
const answeredBy = typeof body.answeredBy === "string" && body.answeredBy.trim()
|
|
2966
4512
|
? body.answeredBy.trim()
|
|
2967
4513
|
: "human";
|
|
2968
4514
|
|
|
2969
|
-
if (!answer) {
|
|
2970
|
-
sendError(res, 400, "answer is required");
|
|
2971
|
-
return;
|
|
2972
|
-
}
|
|
2973
|
-
|
|
2974
4515
|
const currentState = getTeamState();
|
|
2975
4516
|
const currentClarification = currentState?.clarifications[clarificationId];
|
|
2976
4517
|
if (!currentClarification) {
|
|
@@ -2978,6 +4519,19 @@ async function handleRequest(
|
|
|
2978
4519
|
return;
|
|
2979
4520
|
}
|
|
2980
4521
|
|
|
4522
|
+
const answer = buildStructuredClarificationAnswer(currentClarification, {
|
|
4523
|
+
answer: typeof body.answer === "string" ? body.answer.trim() : "",
|
|
4524
|
+
answerValue,
|
|
4525
|
+
answerValues,
|
|
4526
|
+
answerNumber,
|
|
4527
|
+
answerComment,
|
|
4528
|
+
});
|
|
4529
|
+
|
|
4530
|
+
if (!answer) {
|
|
4531
|
+
sendError(res, 400, "answer is required");
|
|
4532
|
+
return;
|
|
4533
|
+
}
|
|
4534
|
+
|
|
2981
4535
|
if (currentClarification.status === "answered") {
|
|
2982
4536
|
sendError(res, 409, "Clarification request already answered");
|
|
2983
4537
|
return;
|
|
@@ -2992,6 +4546,10 @@ async function handleRequest(
|
|
|
2992
4546
|
|
|
2993
4547
|
clarification.status = "answered";
|
|
2994
4548
|
clarification.answer = answer;
|
|
4549
|
+
clarification.answerValue = answerValue;
|
|
4550
|
+
clarification.answerValues = answerValues;
|
|
4551
|
+
clarification.answerNumber = answerNumber;
|
|
4552
|
+
clarification.answerComment = answerComment;
|
|
2995
4553
|
clarification.answeredBy = answeredBy;
|
|
2996
4554
|
clarification.answeredAt = now;
|
|
2997
4555
|
clarification.updatedAt = now;
|
|
@@ -3009,7 +4567,7 @@ async function handleRequest(
|
|
|
3009
4567
|
});
|
|
3010
4568
|
|
|
3011
4569
|
const clarification = state.clarifications[clarificationId];
|
|
3012
|
-
const task = clarification ? state.tasks[clarification.taskId] : undefined;
|
|
4570
|
+
const task = clarification?.taskId ? state.tasks[clarification.taskId] : undefined;
|
|
3013
4571
|
|
|
3014
4572
|
let responseMessage: TeamMessage | undefined;
|
|
3015
4573
|
if (clarification?.requestedByRole && task) {
|
|
@@ -3043,6 +4601,13 @@ async function handleRequest(
|
|
|
3043
4601
|
|
|
3044
4602
|
let resumedTask = task;
|
|
3045
4603
|
let resumedWorker: WorkerInfo | null = null;
|
|
4604
|
+
let continuedControllerRun: {
|
|
4605
|
+
sessionKey: string;
|
|
4606
|
+
controllerRunId?: string;
|
|
4607
|
+
runId?: string;
|
|
4608
|
+
reply?: string;
|
|
4609
|
+
queued?: boolean;
|
|
4610
|
+
} | null = null;
|
|
3046
4611
|
if (task) {
|
|
3047
4612
|
const latestState = getTeamState()!;
|
|
3048
4613
|
if (clarification?.requestedByWorkerId && latestState.workers[clarification.requestedByWorkerId]?.status === "idle") {
|
|
@@ -3056,6 +4621,25 @@ async function handleRequest(
|
|
|
3056
4621
|
assignedRole: task.assignedRole,
|
|
3057
4622
|
});
|
|
3058
4623
|
}
|
|
4624
|
+
} else if (clarification?.controllerRunId) {
|
|
4625
|
+
const targetSessionKey = clarification.controllerSessionKey
|
|
4626
|
+
|| state.controllerRuns[clarification.controllerRunId]?.sessionKey;
|
|
4627
|
+
if (targetSessionKey) {
|
|
4628
|
+
continuedControllerRun = {
|
|
4629
|
+
sessionKey: targetSessionKey,
|
|
4630
|
+
controllerRunId: clarification.controllerRunId,
|
|
4631
|
+
queued: true,
|
|
4632
|
+
};
|
|
4633
|
+
void runControllerIntake(
|
|
4634
|
+
buildControllerClarificationAnswerMessage(clarification, answer, answeredBy),
|
|
4635
|
+
targetSessionKey,
|
|
4636
|
+
deps,
|
|
4637
|
+
).catch((err) => {
|
|
4638
|
+
deps.logger.warn(
|
|
4639
|
+
`Controller: failed to continue intake after clarification ${clarification.id}: ${String(err)}`,
|
|
4640
|
+
);
|
|
4641
|
+
});
|
|
4642
|
+
}
|
|
3059
4643
|
}
|
|
3060
4644
|
|
|
3061
4645
|
wsServer.broadcastUpdate({ type: "clarification:answered", data: clarification });
|
|
@@ -3075,6 +4659,7 @@ async function handleRequest(
|
|
|
3075
4659
|
clarification,
|
|
3076
4660
|
task: serializeTask(resumedTask),
|
|
3077
4661
|
resumedWorker,
|
|
4662
|
+
controllerRun: continuedControllerRun,
|
|
3078
4663
|
message: responseMessage,
|
|
3079
4664
|
});
|
|
3080
4665
|
return;
|
|
@@ -3180,7 +4765,10 @@ async function handleRequest(
|
|
|
3180
4765
|
|
|
3181
4766
|
// GET /api/v1/team/status
|
|
3182
4767
|
if (req.method === "GET" && pathname === "/api/v1/team/status") {
|
|
3183
|
-
const state =
|
|
4768
|
+
const state = reconcileControllerClarifications(deps);
|
|
4769
|
+
const startupReadiness = getEffectiveStartupReadiness(deps, state);
|
|
4770
|
+
const modelReadiness = getTeamClawModelReadiness();
|
|
4771
|
+
const externalWorkerInstall = buildExternalWorkerInstallInfo(req, config);
|
|
3184
4772
|
if (!state) {
|
|
3185
4773
|
sendJson(res, 200, {
|
|
3186
4774
|
teamName: config.teamName,
|
|
@@ -3189,8 +4777,12 @@ async function handleRequest(
|
|
|
3189
4777
|
controllerRuns: [],
|
|
3190
4778
|
messages: [],
|
|
3191
4779
|
clarifications: [],
|
|
4780
|
+
previews: [],
|
|
3192
4781
|
repo: null,
|
|
3193
4782
|
pendingClarificationCount: 0,
|
|
4783
|
+
modelReadiness,
|
|
4784
|
+
externalWorkerInstall,
|
|
4785
|
+
provisioning: startupReadiness ? { startupReadiness } : undefined,
|
|
3194
4786
|
});
|
|
3195
4787
|
return;
|
|
3196
4788
|
}
|
|
@@ -3205,7 +4797,14 @@ async function handleRequest(
|
|
|
3205
4797
|
.map((run) => serializeControllerRun(run)),
|
|
3206
4798
|
messages: state.messages,
|
|
3207
4799
|
clarifications,
|
|
4800
|
+
previews: Object.values(state.previews ?? {}),
|
|
3208
4801
|
repo: state.repo ?? null,
|
|
4802
|
+
modelReadiness,
|
|
4803
|
+
externalWorkerInstall,
|
|
4804
|
+
provisioning: {
|
|
4805
|
+
...(state.provisioning ?? { workers: {} }),
|
|
4806
|
+
startupReadiness,
|
|
4807
|
+
},
|
|
3209
4808
|
taskCount: Object.keys(state.tasks).length,
|
|
3210
4809
|
workerCount: Object.keys(state.workers).length,
|
|
3211
4810
|
pendingClarificationCount: clarifications.filter((item) => item.status === "pending").length,
|
|
@@ -3219,9 +4818,145 @@ async function handleRequest(
|
|
|
3219
4818
|
return;
|
|
3220
4819
|
}
|
|
3221
4820
|
|
|
4821
|
+
// /api/v1/previews/:id/* — proxy to preview subprocess
|
|
4822
|
+
if (req.method === "GET" || req.method === "HEAD" || req.method === "POST" || req.method === "PUT" || req.method === "DELETE") {
|
|
4823
|
+
const previewPrefix = "/api/v1/previews/";
|
|
4824
|
+
if (pathname.startsWith(previewPrefix)) {
|
|
4825
|
+
const remaining = pathname.slice(previewPrefix.length);
|
|
4826
|
+
const slashIdx = remaining.indexOf("/");
|
|
4827
|
+
const previewId = slashIdx >= 0 ? remaining.slice(0, slashIdx) : remaining;
|
|
4828
|
+
const subPath = slashIdx >= 0 ? remaining.slice(slashIdx) : "/";
|
|
4829
|
+
|
|
4830
|
+
const state = deps.getTeamState();
|
|
4831
|
+
const preview = state?.previews?.[previewId];
|
|
4832
|
+
if (!preview || preview.status !== "healthy") {
|
|
4833
|
+
const status = preview?.status ?? "unknown";
|
|
4834
|
+
const errMsg = preview?.lastError;
|
|
4835
|
+
sendJson(res, 503, { error: "Preview not available", previewId, status, lastError: errMsg ?? undefined });
|
|
4836
|
+
return;
|
|
4837
|
+
}
|
|
4838
|
+
|
|
4839
|
+
const proxyPathPrefix = `/api/v1/previews/${encodeURIComponent(previewId)}`;
|
|
4840
|
+
const proxyReq = http.request(
|
|
4841
|
+
{
|
|
4842
|
+
hostname: "127.0.0.1",
|
|
4843
|
+
port: preview.targetPort,
|
|
4844
|
+
path: subPath + (requestUrl.search || ""),
|
|
4845
|
+
method: req.method,
|
|
4846
|
+
headers: {
|
|
4847
|
+
...req.headers,
|
|
4848
|
+
host: `127.0.0.1:${preview.targetPort}`,
|
|
4849
|
+
"accept-encoding": "identity",
|
|
4850
|
+
"x-forwarded-for": req.socket.remoteAddress ?? "",
|
|
4851
|
+
"x-forwarded-host": req.headers.host ?? "",
|
|
4852
|
+
"x-forwarded-proto": "http",
|
|
4853
|
+
},
|
|
4854
|
+
},
|
|
4855
|
+
(proxyRes: http.IncomingMessage) => {
|
|
4856
|
+
const contentType = (proxyRes.headers["content-type"] ?? "").toLowerCase();
|
|
4857
|
+
if (proxyRes.statusCode !== 200 && proxyRes.statusCode !== 201 && proxyRes.statusCode !== 301 && proxyRes.statusCode !== 302 && proxyRes.statusCode !== 304) {
|
|
4858
|
+
res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers);
|
|
4859
|
+
proxyRes.pipe(res);
|
|
4860
|
+
return;
|
|
4861
|
+
}
|
|
4862
|
+
|
|
4863
|
+
if (contentType.includes("text/html")) {
|
|
4864
|
+
const chunks: Buffer[] = [];
|
|
4865
|
+
proxyRes.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
4866
|
+
proxyRes.on("end", () => {
|
|
4867
|
+
let raw = Buffer.concat(chunks);
|
|
4868
|
+
// Decompress if the upstream response is compressed (brotli/gzip/deflate).
|
|
4869
|
+
const encoding = (proxyRes.headers["content-encoding"] ?? "").toLowerCase();
|
|
4870
|
+
try {
|
|
4871
|
+
if (encoding === "br") {
|
|
4872
|
+
raw = zlib.brotliDecompressSync(raw);
|
|
4873
|
+
} else if (encoding === "gzip") {
|
|
4874
|
+
raw = zlib.gunzipSync(raw);
|
|
4875
|
+
} else if (encoding === "deflate") {
|
|
4876
|
+
raw = zlib.inflateSync(raw);
|
|
4877
|
+
}
|
|
4878
|
+
} catch {
|
|
4879
|
+
// If decompression fails, use raw bytes as-is.
|
|
4880
|
+
}
|
|
4881
|
+
const body = raw.toString("utf-8");
|
|
4882
|
+
// Rewrite absolute-path references (href="/...", src="/...", action="/...")
|
|
4883
|
+
// to include the proxy prefix so all navigation stays within the proxy.
|
|
4884
|
+
const rewritten = body.replace(
|
|
4885
|
+
/\b(href|src|action)\s*=\s*"\/([^"]*)"/g,
|
|
4886
|
+
`$1="${proxyPathPrefix}/$2"`,
|
|
4887
|
+
).replace(
|
|
4888
|
+
/\b(href|src|action)\s*=\s*'\/([^']*)'/g,
|
|
4889
|
+
`$1='${proxyPathPrefix}/$2'`,
|
|
4890
|
+
);
|
|
4891
|
+
const resHeaders = { ...proxyRes.headers };
|
|
4892
|
+
// We've decoded and rewritten the body — remove upstream encoding headers.
|
|
4893
|
+
delete resHeaders["content-encoding"];
|
|
4894
|
+
delete resHeaders["transfer-encoding"];
|
|
4895
|
+
resHeaders["content-length"] = String(Buffer.byteLength(rewritten, "utf-8"));
|
|
4896
|
+
res.writeHead(proxyRes.statusCode ?? 200, resHeaders);
|
|
4897
|
+
res.end(rewritten);
|
|
4898
|
+
});
|
|
4899
|
+
} else {
|
|
4900
|
+
res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers);
|
|
4901
|
+
proxyRes.pipe(res);
|
|
4902
|
+
}
|
|
4903
|
+
},
|
|
4904
|
+
);
|
|
4905
|
+
proxyReq.on("error", (err: Error) => {
|
|
4906
|
+
deps.logger.warn(`Controller: preview proxy error for ${previewId}: ${String(err)}`);
|
|
4907
|
+
if (!res.headersSent) {
|
|
4908
|
+
sendJson(res, 502, { error: "Preview proxy error", previewId, message: String(err) });
|
|
4909
|
+
}
|
|
4910
|
+
});
|
|
4911
|
+
req.pipe(proxyReq);
|
|
4912
|
+
return;
|
|
4913
|
+
}
|
|
4914
|
+
}
|
|
4915
|
+
|
|
4916
|
+
// GET /api/v1/reports — list all delivery reports
|
|
4917
|
+
if (req.method === "GET" && pathname === "/api/v1/reports") {
|
|
4918
|
+
const state = deps.getTeamState();
|
|
4919
|
+
const reports = Object.values(state?.reports ?? {}).sort((a, b) => b.generatedAt - a.generatedAt);
|
|
4920
|
+
sendJson(res, 200, { reports });
|
|
4921
|
+
return;
|
|
4922
|
+
}
|
|
4923
|
+
|
|
4924
|
+
// GET /api/v1/reports/:sessionKey — serve delivery report page
|
|
4925
|
+
if (req.method === "GET" && pathname.startsWith("/api/v1/reports/")) {
|
|
4926
|
+
const sessionKey = decodeURIComponent(pathname.slice("/api/v1/reports/".length));
|
|
4927
|
+
const state = deps.getTeamState();
|
|
4928
|
+
if (!state) {
|
|
4929
|
+
sendError(res, 503, "Team state not loaded");
|
|
4930
|
+
return;
|
|
4931
|
+
}
|
|
4932
|
+
const report = generateDeliveryReport(sessionKey, state, normalizeControllerIntakeSessionKey);
|
|
4933
|
+
if (!report) {
|
|
4934
|
+
sendError(res, 404, "No report found for this session");
|
|
4935
|
+
return;
|
|
4936
|
+
}
|
|
4937
|
+
const accept = (req.headers["accept"] ?? "").toLowerCase();
|
|
4938
|
+
if (accept.includes("application/json")) {
|
|
4939
|
+
sendJson(res, 200, { report });
|
|
4940
|
+
} else {
|
|
4941
|
+
const html = renderReportHtml(report);
|
|
4942
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Content-Length": Buffer.byteLength(html, "utf-8") });
|
|
4943
|
+
res.end(html);
|
|
4944
|
+
}
|
|
4945
|
+
return;
|
|
4946
|
+
}
|
|
4947
|
+
|
|
3222
4948
|
// GET /api/v1/health
|
|
3223
4949
|
if (req.method === "GET" && pathname === "/api/v1/health") {
|
|
3224
|
-
|
|
4950
|
+
const readiness = getEffectiveStartupReadiness(deps, currentState);
|
|
4951
|
+
const modelReadiness = getTeamClawModelReadiness();
|
|
4952
|
+
const statusCode = readiness && readiness.status !== "ready" ? 503 : 200;
|
|
4953
|
+
sendJson(res, statusCode, {
|
|
4954
|
+
status: readiness?.status === "degraded" ? "degraded" : readiness?.status === "checking" ? "starting" : "ok",
|
|
4955
|
+
mode: "controller",
|
|
4956
|
+
timestamp: Date.now(),
|
|
4957
|
+
provisioningReadiness: readiness,
|
|
4958
|
+
modelReadiness,
|
|
4959
|
+
});
|
|
3225
4960
|
return;
|
|
3226
4961
|
}
|
|
3227
4962
|
|