@teamclaws/teamclaw 2026.3.25 → 2026.3.26-1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -0
- package/cli.mjs +519 -28
- package/index.ts +31 -16
- package/openclaw.plugin.json +3 -3
- package/package.json +16 -4
- package/src/config.ts +2 -2
- package/src/controller/controller-capacity.ts +23 -0
- package/src/controller/controller-service.ts +27 -1
- package/src/controller/controller-tools.ts +251 -7
- package/src/controller/http-server.ts +976 -38
- package/src/controller/orchestration-manifest.ts +105 -0
- package/src/controller/prompt-injector.ts +42 -7
- package/src/controller/worker-provisioning.ts +171 -13
- package/src/git-collaboration.ts +2 -0
- package/src/interaction-contracts.ts +459 -0
- package/src/task-executor.ts +482 -33
- package/src/types.ts +96 -0
- package/src/ui/app.js +313 -8
- package/src/ui/style.css +152 -0
- package/src/worker/http-handler.ts +15 -7
- package/src/worker/prompt-injector.ts +2 -0
- package/src/worker/skill-installer.ts +13 -0
- package/src/worker/tools.ts +172 -3
- package/src/worker/worker-service.ts +9 -6
|
@@ -5,6 +5,7 @@ import type { IncomingMessage, ServerResponse } from "node:http";
|
|
|
5
5
|
import type { OpenClawPluginApi, PluginLogger } from "../../api.js";
|
|
6
6
|
import type {
|
|
7
7
|
ClarificationRequest,
|
|
8
|
+
ControllerOrchestrationManifest,
|
|
8
9
|
ControllerRunInfo,
|
|
9
10
|
ControllerRunSource,
|
|
10
11
|
GitRepoState,
|
|
@@ -21,7 +22,9 @@ import type {
|
|
|
21
22
|
TaskStatus,
|
|
22
23
|
TeamMessage,
|
|
23
24
|
TeamState,
|
|
25
|
+
WorkerProgressContract,
|
|
24
26
|
WorkerInfo,
|
|
27
|
+
WorkerTaskResultContract,
|
|
25
28
|
} from "../types.js";
|
|
26
29
|
import {
|
|
27
30
|
parseJsonBody,
|
|
@@ -38,6 +41,17 @@ import { TaskRouter } from "./task-router.js";
|
|
|
38
41
|
import { MessageRouter } from "./message-router.js";
|
|
39
42
|
import { TeamWebSocketServer } from "./websocket.js";
|
|
40
43
|
import type { WorkerProvisioningManager } from "./worker-provisioning.js";
|
|
44
|
+
import { createControllerPromptInjector } from "./prompt-injector.js";
|
|
45
|
+
import { buildControllerNoWorkersMessage, shouldBlockControllerWithoutWorkers } from "./controller-capacity.js";
|
|
46
|
+
import {
|
|
47
|
+
backfillWorkerProgressContract,
|
|
48
|
+
backfillWorkerTaskResultContract,
|
|
49
|
+
ensureTeamMessageContract,
|
|
50
|
+
normalizeTaskHandoffContract,
|
|
51
|
+
normalizeWorkerProgressContract,
|
|
52
|
+
normalizeWorkerTaskResultContract,
|
|
53
|
+
} from "../interaction-contracts.js";
|
|
54
|
+
import { normalizeControllerManifest } from "./orchestration-manifest.js";
|
|
41
55
|
|
|
42
56
|
export type ControllerHttpDeps = {
|
|
43
57
|
config: PluginConfig;
|
|
@@ -56,8 +70,23 @@ const MAX_TASK_EXECUTION_EVENTS = 250;
|
|
|
56
70
|
const MAX_CONTROLLER_RUNS = 40;
|
|
57
71
|
const MAX_RECENT_TASK_CONTEXT = 3;
|
|
58
72
|
const MAX_TASK_CONTEXT_SUMMARY_CHARS = 500;
|
|
59
|
-
const CONTROLLER_INTAKE_TIMEOUT_CAP_MS = 180_000;
|
|
60
73
|
const CONTROLLER_INTAKE_SESSION_PREFIX = "teamclaw-controller-web:";
|
|
74
|
+
const CONTROLLER_INTAKE_AGENT_SESSION_RE = /^agent:[^:]+:(teamclaw-controller-web:[a-zA-Z0-9:_-]{1,120})$/;
|
|
75
|
+
const CONTROLLER_RUN_WAIT_SLICE_MS = 30_000;
|
|
76
|
+
const CONTROLLER_RATE_LIMIT_STALL_PROBE_MS = 5 * 60 * 1000;
|
|
77
|
+
const CONTROLLER_RATE_LIMIT_PROBE_TIMEOUT_MS = 60_000;
|
|
78
|
+
const CONTROLLER_RATE_LIMIT_WAITING_SENTINEL = "TEAMCLAW_STILL_WAITING";
|
|
79
|
+
const controllerIntakeQueue = new Map<string, Promise<void>>();
|
|
80
|
+
|
|
81
|
+
export function buildControllerIntakeSystemPrompt(
|
|
82
|
+
deps: Pick<ControllerHttpDeps, "config" | "getTeamState">,
|
|
83
|
+
): string {
|
|
84
|
+
const injector = createControllerPromptInjector({
|
|
85
|
+
config: deps.config,
|
|
86
|
+
getTeamState: deps.getTeamState,
|
|
87
|
+
});
|
|
88
|
+
return injector()?.prependSystemContext ?? "";
|
|
89
|
+
}
|
|
61
90
|
|
|
62
91
|
function mapTaskStatusToExecutionStatus(taskStatus: TaskStatus, current?: TaskExecution["status"]): TaskExecution["status"] {
|
|
63
92
|
switch (taskStatus) {
|
|
@@ -101,6 +130,33 @@ function ensureTaskExecution(task: TaskInfo): TaskExecution {
|
|
|
101
130
|
return task.execution;
|
|
102
131
|
}
|
|
103
132
|
|
|
133
|
+
function resetTaskForFreshAttempt(task: TaskInfo): void {
|
|
134
|
+
delete task.startedAt;
|
|
135
|
+
delete task.completedAt;
|
|
136
|
+
delete task.result;
|
|
137
|
+
delete task.error;
|
|
138
|
+
delete task.resultContract;
|
|
139
|
+
if (task.execution) {
|
|
140
|
+
task.execution.status = "pending";
|
|
141
|
+
delete task.execution.runId;
|
|
142
|
+
delete task.execution.sessionKey;
|
|
143
|
+
delete task.execution.startedAt;
|
|
144
|
+
delete task.execution.endedAt;
|
|
145
|
+
task.execution.lastUpdatedAt = Date.now();
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function buildTaskExecutionIdentity(taskId: string, workerId: string): {
|
|
150
|
+
executionSessionKey: string;
|
|
151
|
+
executionIdempotencyKey: string;
|
|
152
|
+
} {
|
|
153
|
+
const attemptId = generateId();
|
|
154
|
+
return {
|
|
155
|
+
executionSessionKey: `teamclaw-task-${taskId}-${attemptId}`,
|
|
156
|
+
executionIdempotencyKey: `teamclaw-${taskId}-${workerId}-${attemptId}`,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
104
160
|
function appendTaskExecutionEvent(task: TaskInfo, input: TaskExecutionEventInput): TaskExecutionEvent {
|
|
105
161
|
const now = input.createdAt ?? Date.now();
|
|
106
162
|
const execution = ensureTaskExecution(task);
|
|
@@ -417,6 +473,29 @@ function extractLastAssistantText(messages: unknown[]): string {
|
|
|
417
473
|
return JSON.stringify(lastAssistant);
|
|
418
474
|
}
|
|
419
475
|
|
|
476
|
+
function formatDuration(timeoutMs: number): string {
|
|
477
|
+
const totalSeconds = Math.ceil(timeoutMs / 1000);
|
|
478
|
+
if (totalSeconds % 3600 === 0) {
|
|
479
|
+
const hours = totalSeconds / 3600;
|
|
480
|
+
return `${hours} hour${hours === 1 ? "" : "s"}`;
|
|
481
|
+
}
|
|
482
|
+
if (totalSeconds % 60 === 0) {
|
|
483
|
+
const minutes = totalSeconds / 60;
|
|
484
|
+
return `${minutes} minute${minutes === 1 ? "" : "s"}`;
|
|
485
|
+
}
|
|
486
|
+
return `${totalSeconds} second${totalSeconds === 1 ? "" : "s"}`;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function isRateLimitMessage(value: string): boolean {
|
|
490
|
+
return /(rate[_ ]limit|too many requests|429\b|resource has been exhausted|tokens per day|quota|throttl)/i.test(
|
|
491
|
+
String(value || ""),
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function isStillWaitingResponse(value: string): boolean {
|
|
496
|
+
return value.replace(/\s+/g, " ").trim() === CONTROLLER_RATE_LIMIT_WAITING_SENTINEL;
|
|
497
|
+
}
|
|
498
|
+
|
|
420
499
|
function normalizeControllerIntakeSessionKey(input: unknown): string {
|
|
421
500
|
const fallback = `${CONTROLLER_INTAKE_SESSION_PREFIX}default`;
|
|
422
501
|
if (typeof input !== "string") {
|
|
@@ -424,40 +503,400 @@ function normalizeControllerIntakeSessionKey(input: unknown): string {
|
|
|
424
503
|
}
|
|
425
504
|
|
|
426
505
|
const trimmed = input.trim();
|
|
427
|
-
if (!trimmed
|
|
506
|
+
if (!trimmed) {
|
|
507
|
+
return fallback;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const runtimeMatch = trimmed.match(CONTROLLER_INTAKE_AGENT_SESSION_RE);
|
|
511
|
+
if (trimmed.startsWith("agent:") && !runtimeMatch) {
|
|
428
512
|
return fallback;
|
|
429
513
|
}
|
|
430
514
|
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
515
|
+
const logicalKey = runtimeMatch?.[1] ?? trimmed;
|
|
516
|
+
if (!/^[a-zA-Z0-9:_-]{1,120}$/.test(logicalKey)) {
|
|
517
|
+
return fallback;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
return logicalKey.startsWith(CONTROLLER_INTAKE_SESSION_PREFIX)
|
|
521
|
+
? logicalKey
|
|
522
|
+
: `${CONTROLLER_INTAKE_SESSION_PREFIX}${logicalKey}`;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
async function withSerializedControllerIntake<T>(
|
|
526
|
+
sessionKey: string,
|
|
527
|
+
fn: () => Promise<T>,
|
|
528
|
+
): Promise<T> {
|
|
529
|
+
const normalizedSessionKey = normalizeControllerIntakeSessionKey(sessionKey);
|
|
530
|
+
const previous = controllerIntakeQueue.get(normalizedSessionKey) ?? Promise.resolve();
|
|
531
|
+
let releaseCurrent!: () => void;
|
|
532
|
+
const current = new Promise<void>((resolve) => {
|
|
533
|
+
releaseCurrent = resolve;
|
|
534
|
+
});
|
|
535
|
+
controllerIntakeQueue.set(normalizedSessionKey, current);
|
|
536
|
+
try {
|
|
537
|
+
await previous;
|
|
538
|
+
return await fn();
|
|
539
|
+
} finally {
|
|
540
|
+
releaseCurrent();
|
|
541
|
+
if (controllerIntakeQueue.get(normalizedSessionKey) === current) {
|
|
542
|
+
controllerIntakeQueue.delete(normalizedSessionKey);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
434
545
|
}
|
|
435
546
|
|
|
436
547
|
function collectTaskIds(state: TeamState | null): Set<string> {
|
|
437
548
|
return new Set(Object.keys(state?.tasks ?? {}));
|
|
438
549
|
}
|
|
439
550
|
|
|
551
|
+
function normalizeControllerTaskMatchText(input: unknown): string {
|
|
552
|
+
return typeof input === "string" ? input.replace(/\s+/g, " ").trim().toLowerCase() : "";
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function taskMatchesManifestCreatedTask(
|
|
556
|
+
task: TaskInfo,
|
|
557
|
+
manifestTask: ControllerOrchestrationManifest["createdTasks"][number],
|
|
558
|
+
): boolean {
|
|
559
|
+
if (normalizeControllerTaskMatchText(task.title) !== normalizeControllerTaskMatchText(manifestTask.title)) {
|
|
560
|
+
return false;
|
|
561
|
+
}
|
|
562
|
+
if (manifestTask.assignedRole && task.assignedRole && manifestTask.assignedRole !== task.assignedRole) {
|
|
563
|
+
return false;
|
|
564
|
+
}
|
|
565
|
+
if (manifestTask.assignedRole && !task.assignedRole) {
|
|
566
|
+
return false;
|
|
567
|
+
}
|
|
568
|
+
return task.createdBy === "controller";
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function scoreControllerTaskBindingCandidate(task: TaskInfo): number {
|
|
572
|
+
switch (task.status) {
|
|
573
|
+
case "pending":
|
|
574
|
+
return 6;
|
|
575
|
+
case "assigned":
|
|
576
|
+
return 5;
|
|
577
|
+
case "in_progress":
|
|
578
|
+
return 4;
|
|
579
|
+
case "review":
|
|
580
|
+
return 3;
|
|
581
|
+
case "blocked":
|
|
582
|
+
return 2;
|
|
583
|
+
case "completed":
|
|
584
|
+
return 1;
|
|
585
|
+
case "failed":
|
|
586
|
+
default:
|
|
587
|
+
return 0;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function reconcileControllerManifestTaskBindings(
|
|
592
|
+
sessionKey: string,
|
|
593
|
+
createdTaskIds: string[],
|
|
594
|
+
manifest: ControllerOrchestrationManifest | undefined,
|
|
595
|
+
deps: ControllerHttpDeps,
|
|
596
|
+
): { taskIds: string[]; linkedTaskIds: string[] } {
|
|
597
|
+
if (!manifest || manifest.createdTasks.length === 0) {
|
|
598
|
+
return { taskIds: createdTaskIds, linkedTaskIds: [] };
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
const normalizedSessionKey = normalizeControllerIntakeSessionKey(sessionKey);
|
|
602
|
+
const linkedTaskIds: string[] = [];
|
|
603
|
+
|
|
604
|
+
deps.updateTeamState((state) => {
|
|
605
|
+
const usedTaskIds = new Set(createdTaskIds);
|
|
606
|
+
for (const manifestTask of manifest.createdTasks) {
|
|
607
|
+
const existingTaskId = Array.from(usedTaskIds).find((taskId) => {
|
|
608
|
+
const task = state.tasks[taskId];
|
|
609
|
+
return !!task && taskMatchesManifestCreatedTask(task, manifestTask);
|
|
610
|
+
});
|
|
611
|
+
const matchedTask = existingTaskId
|
|
612
|
+
? state.tasks[existingTaskId]
|
|
613
|
+
: Object.values(state.tasks)
|
|
614
|
+
.filter((task) => !usedTaskIds.has(task.id))
|
|
615
|
+
.filter((task) => taskMatchesManifestCreatedTask(task, manifestTask))
|
|
616
|
+
.sort((left, right) => {
|
|
617
|
+
const scoreDelta = scoreControllerTaskBindingCandidate(right) - scoreControllerTaskBindingCandidate(left);
|
|
618
|
+
if (scoreDelta !== 0) {
|
|
619
|
+
return scoreDelta;
|
|
620
|
+
}
|
|
621
|
+
return right.updatedAt - left.updatedAt;
|
|
622
|
+
})[0];
|
|
623
|
+
|
|
624
|
+
if (!matchedTask) {
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
if (matchedTask.controllerSessionKey !== normalizedSessionKey) {
|
|
629
|
+
matchedTask.controllerSessionKey = normalizedSessionKey;
|
|
630
|
+
}
|
|
631
|
+
if (!usedTaskIds.has(matchedTask.id)) {
|
|
632
|
+
usedTaskIds.add(matchedTask.id);
|
|
633
|
+
linkedTaskIds.push(matchedTask.id);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
return {
|
|
639
|
+
taskIds: Array.from(new Set([...createdTaskIds, ...linkedTaskIds])),
|
|
640
|
+
linkedTaskIds,
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
|
|
440
644
|
function tagControllerCreatedTasks(
|
|
441
645
|
taskIdsBeforeRun: Set<string>,
|
|
442
646
|
sessionKey: string,
|
|
443
647
|
deps: ControllerHttpDeps,
|
|
444
648
|
): string[] {
|
|
649
|
+
const normalizedSessionKey = normalizeControllerIntakeSessionKey(sessionKey);
|
|
445
650
|
const taggedTaskIds: string[] = [];
|
|
446
651
|
deps.updateTeamState((state) => {
|
|
447
652
|
for (const task of Object.values(state.tasks)) {
|
|
448
653
|
if (taskIdsBeforeRun.has(task.id)) {
|
|
449
654
|
continue;
|
|
450
655
|
}
|
|
451
|
-
if (task.createdBy !== "controller"
|
|
656
|
+
if (task.createdBy !== "controller") {
|
|
657
|
+
continue;
|
|
658
|
+
}
|
|
659
|
+
if (!task.controllerSessionKey) {
|
|
660
|
+
task.controllerSessionKey = normalizedSessionKey;
|
|
661
|
+
}
|
|
662
|
+
if (normalizeControllerIntakeSessionKey(task.controllerSessionKey) !== normalizedSessionKey) {
|
|
452
663
|
continue;
|
|
453
664
|
}
|
|
454
|
-
task.controllerSessionKey = sessionKey;
|
|
455
665
|
taggedTaskIds.push(task.id);
|
|
456
666
|
}
|
|
457
667
|
});
|
|
458
668
|
return taggedTaskIds;
|
|
459
669
|
}
|
|
460
670
|
|
|
671
|
+
function isActiveControllerRun(run: ControllerRunInfo): boolean {
|
|
672
|
+
return run.status === "pending" || run.status === "running";
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function findLatestControllerRunIdForSession(
|
|
676
|
+
sessionKey: string,
|
|
677
|
+
state: TeamState | null,
|
|
678
|
+
options?: { preferActive?: boolean },
|
|
679
|
+
): string | null {
|
|
680
|
+
const normalizedSessionKey = normalizeControllerIntakeSessionKey(sessionKey);
|
|
681
|
+
const matchingRuns = Object.values(state?.controllerRuns ?? {})
|
|
682
|
+
.filter((run) => normalizeControllerIntakeSessionKey(run.sessionKey) === normalizedSessionKey)
|
|
683
|
+
.sort((left, right) => {
|
|
684
|
+
if (options?.preferActive) {
|
|
685
|
+
const leftScore = isActiveControllerRun(left) ? 1 : 0;
|
|
686
|
+
const rightScore = isActiveControllerRun(right) ? 1 : 0;
|
|
687
|
+
if (leftScore !== rightScore) {
|
|
688
|
+
return rightScore - leftScore;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
return right.updatedAt - left.updatedAt;
|
|
692
|
+
});
|
|
693
|
+
return matchingRuns[0]?.id ?? null;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function resolveControllerWorkflowSessionKey(task: TaskInfo, state: TeamState | null): string | undefined {
|
|
697
|
+
if (task.controllerSessionKey) {
|
|
698
|
+
return normalizeControllerIntakeSessionKey(task.controllerSessionKey);
|
|
699
|
+
}
|
|
700
|
+
if (!state || task.createdBy !== "controller") {
|
|
701
|
+
return undefined;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
const sortedRuns = Object.values(state.controllerRuns)
|
|
705
|
+
.sort((left, right) => right.updatedAt - left.updatedAt);
|
|
706
|
+
|
|
707
|
+
const directRun = sortedRuns.find((run) =>
|
|
708
|
+
run.sourceTaskId === task.id || run.createdTaskIds.includes(task.id),
|
|
709
|
+
);
|
|
710
|
+
if (directRun) {
|
|
711
|
+
return normalizeControllerIntakeSessionKey(directRun.sessionKey);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
const manifestRun = sortedRuns.find((run) =>
|
|
715
|
+
run.manifest?.createdTasks.some((manifestTask) => taskMatchesManifestCreatedTask(task, manifestTask)),
|
|
716
|
+
);
|
|
717
|
+
return manifestRun ? normalizeControllerIntakeSessionKey(manifestRun.sessionKey) : undefined;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function buildControllerManifestEventMessage(manifest: ControllerOrchestrationManifest): string {
|
|
721
|
+
const parts = [
|
|
722
|
+
`Structured orchestration manifest recorded.`,
|
|
723
|
+
`roles=${manifest.requiredRoles.join(", ") || "none"}`,
|
|
724
|
+
`created=${manifest.createdTasks.length}`,
|
|
725
|
+
`deferred=${manifest.deferredTasks.length}`,
|
|
726
|
+
];
|
|
727
|
+
if (manifest.clarificationsNeeded) {
|
|
728
|
+
parts.push(`clarifications=${manifest.clarificationQuestions.length}`);
|
|
729
|
+
}
|
|
730
|
+
return parts.join(" ");
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function buildControllerManifestReply(
|
|
734
|
+
manifest: ControllerOrchestrationManifest | undefined,
|
|
735
|
+
createdTaskIds: string[],
|
|
736
|
+
state: TeamState | null,
|
|
737
|
+
fallbackReply: string,
|
|
738
|
+
): string {
|
|
739
|
+
if (!manifest) {
|
|
740
|
+
const warning = "Warning: controller did not submit a structured orchestration manifest for this run.";
|
|
741
|
+
return fallbackReply ? `${fallbackReply}\n\n${warning}` : warning;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
const actualCreatedTasks = createdTaskIds
|
|
745
|
+
.map((taskId) => state?.tasks?.[taskId])
|
|
746
|
+
.filter((task): task is TaskInfo => !!task);
|
|
747
|
+
|
|
748
|
+
const lines: string[] = [
|
|
749
|
+
`Requirement summary: ${manifest.requirementSummary}`,
|
|
750
|
+
`Required roles: ${manifest.requiredRoles.join(", ") || "none"}`,
|
|
751
|
+
];
|
|
752
|
+
|
|
753
|
+
if (actualCreatedTasks.length > 0) {
|
|
754
|
+
lines.push("", "Created execution-ready tasks:");
|
|
755
|
+
for (const task of actualCreatedTasks) {
|
|
756
|
+
const roleLabel = task.assignedRole ? ` (${task.assignedRole})` : "";
|
|
757
|
+
lines.push(`- [${task.id}] ${task.title}${roleLabel}`);
|
|
758
|
+
}
|
|
759
|
+
} else if (manifest.createdTasks.length > 0) {
|
|
760
|
+
lines.push("", "Manifest planned created tasks:");
|
|
761
|
+
for (const task of manifest.createdTasks) {
|
|
762
|
+
const roleLabel = task.assignedRole ? ` (${task.assignedRole})` : "";
|
|
763
|
+
lines.push(`- ${task.title}${roleLabel}: ${task.expectedOutcome}`);
|
|
764
|
+
}
|
|
765
|
+
} else {
|
|
766
|
+
lines.push("", "Created execution-ready tasks: none.");
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
if (manifest.deferredTasks.length > 0) {
|
|
770
|
+
lines.push("", "Deferred tasks:");
|
|
771
|
+
for (const task of manifest.deferredTasks) {
|
|
772
|
+
const roleLabel = task.assignedRole ? ` (${task.assignedRole})` : "";
|
|
773
|
+
lines.push(`- ${task.title}${roleLabel}: blocked by ${task.blockedBy}; create when ${task.whenReady}`);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
if (manifest.clarificationsNeeded) {
|
|
778
|
+
lines.push("", "Clarifications needed:");
|
|
779
|
+
for (const question of manifest.clarificationQuestions) {
|
|
780
|
+
lines.push(`- ${question}`);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
if (manifest.handoffPlan) {
|
|
785
|
+
lines.push("", `Handoff plan: ${manifest.handoffPlan}`);
|
|
786
|
+
}
|
|
787
|
+
if (manifest.notes) {
|
|
788
|
+
lines.push("", `Notes: ${manifest.notes}`);
|
|
789
|
+
}
|
|
790
|
+
if (manifest.createdTasks.length !== createdTaskIds.length) {
|
|
791
|
+
lines.push(
|
|
792
|
+
"",
|
|
793
|
+
`Warning: manifest declared ${manifest.createdTasks.length} created task(s), but TeamClaw recorded ${createdTaskIds.length}.`,
|
|
794
|
+
);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
return lines.join("\n");
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
function summarizeManifestExpectedOutcome(task: TaskInfo): string {
|
|
801
|
+
const raw = task.result || task.progress || task.description || "";
|
|
802
|
+
const normalized = raw.replace(/\s+/g, " ").trim();
|
|
803
|
+
if (!normalized) {
|
|
804
|
+
return "Produce the concrete deliverable described by this task and report the result back to the controller.";
|
|
805
|
+
}
|
|
806
|
+
if (normalized.length <= 180) {
|
|
807
|
+
return normalized;
|
|
808
|
+
}
|
|
809
|
+
return `${normalized.slice(0, 180).trimEnd()}…`;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function inferManifestRolesFromText(text: string): RoleId[] {
|
|
813
|
+
const normalized = text.toLowerCase();
|
|
814
|
+
const roleIds: RoleId[] = [];
|
|
815
|
+
for (const role of ROLES) {
|
|
816
|
+
if (normalized.includes(role.id) || normalized.includes(role.label.toLowerCase())) {
|
|
817
|
+
roleIds.push(role.id);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
return roleIds;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function inferClarificationQuestionsFromReply(text: string): string[] {
|
|
824
|
+
const candidates = text
|
|
825
|
+
.split(/\n+/)
|
|
826
|
+
.map((line) => line.trim())
|
|
827
|
+
.filter((line) => line.includes("?"))
|
|
828
|
+
.map((line) => line.replace(/^[-*]\s*/, ""))
|
|
829
|
+
.filter(Boolean);
|
|
830
|
+
return Array.from(new Set(candidates)).slice(0, 5);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
function buildBackfilledControllerManifest(
|
|
834
|
+
request: string,
|
|
835
|
+
rawReply: string,
|
|
836
|
+
createdTaskIds: string[],
|
|
837
|
+
state: TeamState | null,
|
|
838
|
+
): ControllerOrchestrationManifest {
|
|
839
|
+
const actualCreatedTasks = createdTaskIds
|
|
840
|
+
.map((taskId) => state?.tasks?.[taskId])
|
|
841
|
+
.filter((task): task is TaskInfo => !!task);
|
|
842
|
+
const inferredRoles = new Set<RoleId>();
|
|
843
|
+
for (const task of actualCreatedTasks) {
|
|
844
|
+
if (task.assignedRole) {
|
|
845
|
+
inferredRoles.add(task.assignedRole);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
for (const roleId of inferManifestRolesFromText(rawReply)) {
|
|
849
|
+
inferredRoles.add(roleId);
|
|
850
|
+
}
|
|
851
|
+
const clarificationQuestions = inferClarificationQuestionsFromReply(rawReply);
|
|
852
|
+
return {
|
|
853
|
+
version: "1.0",
|
|
854
|
+
requirementSummary: request.replace(/\s+/g, " ").trim() || "Controller requirement summary unavailable.",
|
|
855
|
+
requiredRoles: Array.from(inferredRoles),
|
|
856
|
+
clarificationsNeeded: clarificationQuestions.length > 0 && actualCreatedTasks.length === 0,
|
|
857
|
+
clarificationQuestions,
|
|
858
|
+
createdTasks: actualCreatedTasks.map((task) => ({
|
|
859
|
+
title: task.title,
|
|
860
|
+
assignedRole: task.assignedRole,
|
|
861
|
+
expectedOutcome: summarizeManifestExpectedOutcome(task),
|
|
862
|
+
})),
|
|
863
|
+
deferredTasks: [],
|
|
864
|
+
handoffPlan: actualCreatedTasks.length > 0
|
|
865
|
+
? "Assigned workers should complete the created execution-ready tasks, report progress, and let the controller schedule downstream work after prerequisites are satisfied."
|
|
866
|
+
: undefined,
|
|
867
|
+
notes: "Backfilled by the controller because the model did not submit the required structured manifest.",
|
|
868
|
+
};
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
function ensureControllerManifest(
|
|
872
|
+
controllerRunId: string,
|
|
873
|
+
sessionKey: string,
|
|
874
|
+
request: string,
|
|
875
|
+
rawReply: string,
|
|
876
|
+
createdTaskIds: string[],
|
|
877
|
+
deps: ControllerHttpDeps,
|
|
878
|
+
): ControllerOrchestrationManifest {
|
|
879
|
+
const currentState = deps.getTeamState();
|
|
880
|
+
const existingManifest = currentState?.controllerRuns?.[controllerRunId]?.manifest;
|
|
881
|
+
if (existingManifest) {
|
|
882
|
+
return existingManifest;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
const manifest = buildBackfilledControllerManifest(request, rawReply, createdTaskIds, currentState);
|
|
886
|
+
updateControllerRun(controllerRunId, deps, (run) => {
|
|
887
|
+
run.manifest = manifest;
|
|
888
|
+
appendControllerRunEvent(run, {
|
|
889
|
+
type: "warning",
|
|
890
|
+
phase: "manifest_backfilled",
|
|
891
|
+
source: "controller",
|
|
892
|
+
status: "running",
|
|
893
|
+
sessionKey,
|
|
894
|
+
message: "Controller did not submit a structured manifest; TeamClaw backfilled a minimal manifest from the recorded run state.",
|
|
895
|
+
});
|
|
896
|
+
});
|
|
897
|
+
return manifest;
|
|
898
|
+
}
|
|
899
|
+
|
|
461
900
|
function buildControllerFollowUpMessage(task: TaskInfo): string {
|
|
462
901
|
const parts = [
|
|
463
902
|
`A controller-created TeamClaw task has ${task.status === "failed" ? "failed" : "completed"}.`,
|
|
@@ -472,6 +911,10 @@ function buildControllerFollowUpMessage(task: TaskInfo): string {
|
|
|
472
911
|
if (task.result) {
|
|
473
912
|
parts.push("", "## Task Result", task.result);
|
|
474
913
|
}
|
|
914
|
+
const resultContractSection = buildResultContractSection(task);
|
|
915
|
+
if (resultContractSection) {
|
|
916
|
+
parts.push("", resultContractSection);
|
|
917
|
+
}
|
|
475
918
|
if (task.error) {
|
|
476
919
|
parts.push("", "## Task Error", task.error);
|
|
477
920
|
}
|
|
@@ -489,11 +932,40 @@ function buildControllerFollowUpMessage(task: TaskInfo): string {
|
|
|
489
932
|
return parts.filter(Boolean).join("\n");
|
|
490
933
|
}
|
|
491
934
|
|
|
935
|
+
function buildControllerRateLimitProbeMessage(
|
|
936
|
+
sourceTaskId?: string,
|
|
937
|
+
sourceTaskTitle?: string,
|
|
938
|
+
): string {
|
|
939
|
+
const workflowLabel = sourceTaskTitle
|
|
940
|
+
? `${sourceTaskTitle}${sourceTaskId ? ` (${sourceTaskId})` : ""}`
|
|
941
|
+
: (sourceTaskId ? `task ${sourceTaskId}` : "this controller workflow");
|
|
942
|
+
return [
|
|
943
|
+
`This is a follow-up check for ${workflowLabel}.`,
|
|
944
|
+
"The earlier controller run appears to be delayed by upstream model rate limiting.",
|
|
945
|
+
"Do not restart the workflow from scratch.",
|
|
946
|
+
"Do not duplicate tasks that already exist, are active, or are completed.",
|
|
947
|
+
"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.",
|
|
948
|
+
`If the earlier controller follow-up is not complete yet, reply with exactly ${CONTROLLER_RATE_LIMIT_WAITING_SENTINEL}.`,
|
|
949
|
+
].join("\n");
|
|
950
|
+
}
|
|
951
|
+
|
|
492
952
|
async function continueControllerWorkflow(task: TaskInfo, deps: ControllerHttpDeps): Promise<void> {
|
|
493
|
-
if (task.createdBy !== "controller"
|
|
953
|
+
if (task.createdBy !== "controller") {
|
|
494
954
|
return;
|
|
495
955
|
}
|
|
496
|
-
|
|
956
|
+
const sessionKey = resolveControllerWorkflowSessionKey(task, deps.getTeamState());
|
|
957
|
+
if (!sessionKey) {
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
if (task.controllerSessionKey !== sessionKey) {
|
|
961
|
+
deps.updateTeamState((state) => {
|
|
962
|
+
const currentTask = state.tasks[task.id];
|
|
963
|
+
if (currentTask) {
|
|
964
|
+
currentTask.controllerSessionKey = sessionKey;
|
|
965
|
+
}
|
|
966
|
+
});
|
|
967
|
+
}
|
|
968
|
+
await runControllerIntake(buildControllerFollowUpMessage(task), sessionKey, deps, {
|
|
497
969
|
source: "task_follow_up",
|
|
498
970
|
sourceTaskId: task.id,
|
|
499
971
|
sourceTaskTitle: task.title,
|
|
@@ -509,6 +981,22 @@ async function runControllerIntake(
|
|
|
509
981
|
sourceTaskId?: string;
|
|
510
982
|
sourceTaskTitle?: string;
|
|
511
983
|
},
|
|
984
|
+
): Promise<{ sessionKey: string; runId: string; reply: string; controllerRunId: string }> {
|
|
985
|
+
const normalizedSessionKey = normalizeControllerIntakeSessionKey(sessionKey);
|
|
986
|
+
return withSerializedControllerIntake(normalizedSessionKey, () =>
|
|
987
|
+
runControllerIntakeUnlocked(message, normalizedSessionKey, deps, options),
|
|
988
|
+
);
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
async function runControllerIntakeUnlocked(
|
|
992
|
+
message: string,
|
|
993
|
+
sessionKey: string,
|
|
994
|
+
deps: ControllerHttpDeps,
|
|
995
|
+
options?: {
|
|
996
|
+
source?: ControllerRunSource;
|
|
997
|
+
sourceTaskId?: string;
|
|
998
|
+
sourceTaskTitle?: string;
|
|
999
|
+
},
|
|
512
1000
|
): Promise<{ sessionKey: string; runId: string; reply: string; controllerRunId: string }> {
|
|
513
1001
|
const taskIdsBeforeRun = collectTaskIds(deps.getTeamState());
|
|
514
1002
|
const controllerRun = createControllerRun(message, sessionKey, deps, options);
|
|
@@ -524,6 +1012,7 @@ async function runControllerIntake(
|
|
|
524
1012
|
const runResult = await deps.runtime.subagent.run({
|
|
525
1013
|
sessionKey,
|
|
526
1014
|
message,
|
|
1015
|
+
extraSystemPrompt: buildControllerIntakeSystemPrompt(deps),
|
|
527
1016
|
idempotencyKey: `controller-intake-${generateId()}`,
|
|
528
1017
|
});
|
|
529
1018
|
recordControllerRunEvent(controllerRun.id, {
|
|
@@ -536,10 +1025,133 @@ async function runControllerIntake(
|
|
|
536
1025
|
message: `Controller intake started (${runResult.runId}).`,
|
|
537
1026
|
}, deps);
|
|
538
1027
|
|
|
539
|
-
const
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
1028
|
+
const rateLimitState: {
|
|
1029
|
+
active: boolean;
|
|
1030
|
+
visibleAt?: number;
|
|
1031
|
+
nextProbeAt?: number;
|
|
1032
|
+
probeCount: number;
|
|
1033
|
+
} = {
|
|
1034
|
+
active: false,
|
|
1035
|
+
probeCount: 0,
|
|
1036
|
+
};
|
|
1037
|
+
|
|
1038
|
+
const markRateLimitWaiting = async (): Promise<void> => {
|
|
1039
|
+
if (rateLimitState.active) {
|
|
1040
|
+
return;
|
|
1041
|
+
}
|
|
1042
|
+
const now = Date.now();
|
|
1043
|
+
rateLimitState.active = true;
|
|
1044
|
+
rateLimitState.visibleAt = now;
|
|
1045
|
+
rateLimitState.nextProbeAt = now + CONTROLLER_RATE_LIMIT_STALL_PROBE_MS;
|
|
1046
|
+
recordControllerRunEvent(controllerRun.id, {
|
|
1047
|
+
type: "progress",
|
|
1048
|
+
phase: "model_rate_limit_waiting",
|
|
1049
|
+
source: "controller",
|
|
1050
|
+
status: "running",
|
|
1051
|
+
sessionKey,
|
|
1052
|
+
runId: runResult.runId,
|
|
1053
|
+
message: "Model rate limit reached. OpenClaw is retrying upstream; TeamClaw will keep waiting for the controller workflow to continue.",
|
|
1054
|
+
}, deps);
|
|
1055
|
+
};
|
|
1056
|
+
|
|
1057
|
+
const clearRateLimitWaiting = (): void => {
|
|
1058
|
+
rateLimitState.active = false;
|
|
1059
|
+
rateLimitState.visibleAt = undefined;
|
|
1060
|
+
rateLimitState.nextProbeAt = undefined;
|
|
1061
|
+
};
|
|
1062
|
+
|
|
1063
|
+
const extractSessionAssistantReply = async (): Promise<string> => {
|
|
1064
|
+
const sessionMessages = await deps.runtime.subagent.getSessionMessages({
|
|
1065
|
+
sessionKey,
|
|
1066
|
+
limit: 100,
|
|
1067
|
+
});
|
|
1068
|
+
return extractLastAssistantText(sessionMessages.messages);
|
|
1069
|
+
};
|
|
1070
|
+
|
|
1071
|
+
const probeRateLimitedControllerCompletion = async (): Promise<string | null> => {
|
|
1072
|
+
rateLimitState.probeCount += 1;
|
|
1073
|
+
const now = Date.now();
|
|
1074
|
+
rateLimitState.visibleAt = now;
|
|
1075
|
+
rateLimitState.nextProbeAt = now + CONTROLLER_RATE_LIMIT_STALL_PROBE_MS;
|
|
1076
|
+
recordControllerRunEvent(controllerRun.id, {
|
|
1077
|
+
type: "progress",
|
|
1078
|
+
phase: "model_rate_limit_probe",
|
|
1079
|
+
source: "controller",
|
|
1080
|
+
status: "running",
|
|
1081
|
+
sessionKey,
|
|
1082
|
+
runId: runResult.runId,
|
|
1083
|
+
message: `Model rate limit has delayed controller orchestration for over ${formatDuration(CONTROLLER_RATE_LIMIT_STALL_PROBE_MS)}. Re-checking whether this workflow step has already completed.`,
|
|
1084
|
+
}, deps);
|
|
1085
|
+
|
|
1086
|
+
const probeRun = await deps.runtime.subagent.run({
|
|
1087
|
+
sessionKey,
|
|
1088
|
+
message: buildControllerRateLimitProbeMessage(options?.sourceTaskId, options?.sourceTaskTitle),
|
|
1089
|
+
extraSystemPrompt: buildControllerIntakeSystemPrompt(deps),
|
|
1090
|
+
idempotencyKey: `${runResult.runId}:rate-limit-probe:${rateLimitState.probeCount}`,
|
|
1091
|
+
});
|
|
1092
|
+
const probeWait = await deps.runtime.subagent.waitForRun({
|
|
1093
|
+
runId: probeRun.runId,
|
|
1094
|
+
timeoutMs: CONTROLLER_RATE_LIMIT_PROBE_TIMEOUT_MS,
|
|
1095
|
+
});
|
|
1096
|
+
|
|
1097
|
+
if (probeWait.status !== "ok") {
|
|
1098
|
+
return null;
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
const probeReply = await extractSessionAssistantReply();
|
|
1102
|
+
if (!probeReply || isRateLimitMessage(probeReply) || isStillWaitingResponse(probeReply)) {
|
|
1103
|
+
recordControllerRunEvent(controllerRun.id, {
|
|
1104
|
+
type: "progress",
|
|
1105
|
+
phase: "model_rate_limit_still_waiting",
|
|
1106
|
+
source: "controller",
|
|
1107
|
+
status: "running",
|
|
1108
|
+
sessionKey,
|
|
1109
|
+
runId: runResult.runId,
|
|
1110
|
+
message: "The controller workflow is still waiting on model availability. TeamClaw will continue waiting.",
|
|
1111
|
+
}, deps);
|
|
1112
|
+
return null;
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
clearRateLimitWaiting();
|
|
1116
|
+
return probeReply;
|
|
1117
|
+
};
|
|
1118
|
+
|
|
1119
|
+
let waitResult: Awaited<ReturnType<typeof deps.runtime.subagent.waitForRun>> = { status: "timeout" };
|
|
1120
|
+
let completionOverride: string | null = null;
|
|
1121
|
+
const deadline = Date.now() + deps.config.taskTimeoutMs;
|
|
1122
|
+
while (true) {
|
|
1123
|
+
const remainingMs = deadline - Date.now();
|
|
1124
|
+
if (remainingMs <= 0) {
|
|
1125
|
+
waitResult = { status: "timeout" };
|
|
1126
|
+
break;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
if (rateLimitState.active && (rateLimitState.nextProbeAt ?? Number.POSITIVE_INFINITY) <= Date.now()) {
|
|
1130
|
+
completionOverride = await probeRateLimitedControllerCompletion();
|
|
1131
|
+
if (completionOverride) {
|
|
1132
|
+
waitResult = { status: "ok" };
|
|
1133
|
+
break;
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
const sliceTimeoutMs = Math.max(1_000, Math.min(CONTROLLER_RUN_WAIT_SLICE_MS, remainingMs));
|
|
1138
|
+
waitResult = await deps.runtime.subagent.waitForRun({
|
|
1139
|
+
runId: runResult.runId,
|
|
1140
|
+
timeoutMs: sliceTimeoutMs,
|
|
1141
|
+
});
|
|
1142
|
+
|
|
1143
|
+
if (waitResult.status === "ok") {
|
|
1144
|
+
clearRateLimitWaiting();
|
|
1145
|
+
break;
|
|
1146
|
+
}
|
|
1147
|
+
if (waitResult.status === "error") {
|
|
1148
|
+
if (isRateLimitMessage(waitResult.error || "")) {
|
|
1149
|
+
await markRateLimitWaiting();
|
|
1150
|
+
continue;
|
|
1151
|
+
}
|
|
1152
|
+
break;
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
543
1155
|
|
|
544
1156
|
if (waitResult.status === "timeout") {
|
|
545
1157
|
const createdTaskIds = tagControllerCreatedTasks(taskIdsBeforeRun, sessionKey, deps);
|
|
@@ -579,17 +1191,24 @@ async function runControllerIntake(
|
|
|
579
1191
|
|
|
580
1192
|
const createdTaskIds = tagControllerCreatedTasks(taskIdsBeforeRun, sessionKey, deps);
|
|
581
1193
|
|
|
582
|
-
const
|
|
583
|
-
sessionKey,
|
|
584
|
-
limit: 100,
|
|
585
|
-
});
|
|
586
|
-
const reply = extractLastAssistantText(sessionMessages.messages)
|
|
1194
|
+
const rawReply = completionOverride || await extractSessionAssistantReply()
|
|
587
1195
|
|| "Controller completed the intake run but did not return any text.";
|
|
1196
|
+
const recordedManifest = ensureControllerManifest(
|
|
1197
|
+
controllerRun.id,
|
|
1198
|
+
sessionKey,
|
|
1199
|
+
message,
|
|
1200
|
+
rawReply,
|
|
1201
|
+
createdTaskIds,
|
|
1202
|
+
deps,
|
|
1203
|
+
);
|
|
1204
|
+
const reconciledTasks = reconcileControllerManifestTaskBindings(sessionKey, createdTaskIds, recordedManifest, deps);
|
|
1205
|
+
const latestTeamState = deps.getTeamState();
|
|
1206
|
+
const reply = buildControllerManifestReply(recordedManifest, reconciledTasks.taskIds, latestTeamState, rawReply);
|
|
588
1207
|
|
|
589
1208
|
updateControllerRun(controllerRun.id, deps, (run) => {
|
|
590
1209
|
run.reply = reply;
|
|
591
1210
|
run.error = undefined;
|
|
592
|
-
run.createdTaskIds =
|
|
1211
|
+
run.createdTaskIds = reconciledTasks.taskIds;
|
|
593
1212
|
appendControllerRunEvent(run, {
|
|
594
1213
|
type: "output",
|
|
595
1214
|
phase: "final_reply",
|
|
@@ -599,7 +1218,18 @@ async function runControllerIntake(
|
|
|
599
1218
|
runId: runResult.runId,
|
|
600
1219
|
message: reply,
|
|
601
1220
|
});
|
|
602
|
-
if (
|
|
1221
|
+
if (reconciledTasks.linkedTaskIds.length > 0) {
|
|
1222
|
+
appendControllerRunEvent(run, {
|
|
1223
|
+
type: "lifecycle",
|
|
1224
|
+
phase: "tasks_linked",
|
|
1225
|
+
source: "controller",
|
|
1226
|
+
status: "running",
|
|
1227
|
+
sessionKey,
|
|
1228
|
+
runId: runResult.runId,
|
|
1229
|
+
message: `Controller linked ${reconciledTasks.linkedTaskIds.length} existing task(s) into this workflow: ${reconciledTasks.linkedTaskIds.join(", ")}`,
|
|
1230
|
+
});
|
|
1231
|
+
}
|
|
1232
|
+
if (reconciledTasks.taskIds.length > 0) {
|
|
603
1233
|
appendControllerRunEvent(run, {
|
|
604
1234
|
type: "lifecycle",
|
|
605
1235
|
phase: "tasks_created",
|
|
@@ -607,7 +1237,7 @@ async function runControllerIntake(
|
|
|
607
1237
|
status: "running",
|
|
608
1238
|
sessionKey,
|
|
609
1239
|
runId: runResult.runId,
|
|
610
|
-
message: `Controller
|
|
1240
|
+
message: `Controller activated ${reconciledTasks.taskIds.length} execution-ready task(s): ${reconciledTasks.taskIds.join(", ")}`,
|
|
611
1241
|
});
|
|
612
1242
|
}
|
|
613
1243
|
appendControllerRunEvent(run, {
|
|
@@ -631,7 +1261,13 @@ async function runControllerIntake(
|
|
|
631
1261
|
|
|
632
1262
|
function summarizeTaskForAssignment(task: TaskInfo): string {
|
|
633
1263
|
const lastExecutionMessage = task.execution?.events[task.execution.events.length - 1]?.message;
|
|
634
|
-
const
|
|
1264
|
+
const contractSummary = task.resultContract
|
|
1265
|
+
? [
|
|
1266
|
+
task.resultContract.summary,
|
|
1267
|
+
...task.resultContract.deliverables.slice(0, 3).map((deliverable) => `${deliverable.kind}: ${deliverable.value}`),
|
|
1268
|
+
].join(" | ")
|
|
1269
|
+
: "";
|
|
1270
|
+
const raw = contractSummary || task.result || task.progress || lastExecutionMessage || task.description || "";
|
|
635
1271
|
const normalized = raw.replace(/\s+/g, " ").trim();
|
|
636
1272
|
if (!normalized) {
|
|
637
1273
|
return "No upstream summary available.";
|
|
@@ -911,8 +1547,11 @@ function applyTaskResult(
|
|
|
911
1547
|
});
|
|
912
1548
|
|
|
913
1549
|
if (task.assignedWorkerId && s.workers[task.assignedWorkerId]) {
|
|
914
|
-
s.workers[task.assignedWorkerId]
|
|
915
|
-
|
|
1550
|
+
const assignedWorker = s.workers[task.assignedWorkerId];
|
|
1551
|
+
if (assignedWorker.status !== "offline") {
|
|
1552
|
+
assignedWorker.status = "idle";
|
|
1553
|
+
}
|
|
1554
|
+
assignedWorker.currentTaskId = undefined;
|
|
916
1555
|
}
|
|
917
1556
|
});
|
|
918
1557
|
|
|
@@ -923,6 +1562,14 @@ function applyTaskResult(
|
|
|
923
1562
|
}
|
|
924
1563
|
wsServer.broadcastUpdate({ type: "task:completed", data: serializeTask(updatedTask) });
|
|
925
1564
|
logger.info(`Controller: task ${taskId} ${error ? "failed" : "completed"}`);
|
|
1565
|
+
if (error && updatedTask.assignedWorkerId && deps.workerProvisioningManager?.hasManagedWorker(updatedTask.assignedWorkerId)) {
|
|
1566
|
+
void deps.workerProvisioningManager.onWorkerRemoved(
|
|
1567
|
+
updatedTask.assignedWorkerId,
|
|
1568
|
+
`task ${taskId} failed; retiring managed worker before retry`,
|
|
1569
|
+
).catch((err) => {
|
|
1570
|
+
logger.warn(`Controller: failed to retire managed worker ${updatedTask.assignedWorkerId}: ${String(err)}`);
|
|
1571
|
+
});
|
|
1572
|
+
}
|
|
926
1573
|
if (updatedTask.assignedWorkerId) {
|
|
927
1574
|
void autoAssignPendingTasks(deps, updatedTask.assignedWorkerId).catch((err) => {
|
|
928
1575
|
logger.warn(
|
|
@@ -931,7 +1578,7 @@ function applyTaskResult(
|
|
|
931
1578
|
});
|
|
932
1579
|
}
|
|
933
1580
|
scheduleProvisioningReconcile(deps, `task-result:${taskId}`);
|
|
934
|
-
if (!error && updatedTask.createdBy === "controller"
|
|
1581
|
+
if (!error && updatedTask.createdBy === "controller") {
|
|
935
1582
|
void continueControllerWorkflow(updatedTask, deps).catch((err) => {
|
|
936
1583
|
logger.warn(
|
|
937
1584
|
`Controller: failed to continue intake workflow after ${taskId}: ${String(err)}`,
|
|
@@ -943,6 +1590,89 @@ function applyTaskResult(
|
|
|
943
1590
|
return updatedTask;
|
|
944
1591
|
}
|
|
945
1592
|
|
|
1593
|
+
function ensureTaskResultContract(
|
|
1594
|
+
taskId: string,
|
|
1595
|
+
result: string,
|
|
1596
|
+
error: string | undefined,
|
|
1597
|
+
deps: ControllerHttpDeps,
|
|
1598
|
+
): WorkerTaskResultContract | undefined {
|
|
1599
|
+
const state = deps.getTeamState();
|
|
1600
|
+
const currentTask = state?.tasks[taskId];
|
|
1601
|
+
if (!currentTask) {
|
|
1602
|
+
return undefined;
|
|
1603
|
+
}
|
|
1604
|
+
if (currentTask.resultContract) {
|
|
1605
|
+
return currentTask.resultContract;
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
const contract = backfillWorkerTaskResultContract(currentTask, result, error);
|
|
1609
|
+
deps.updateTeamState((teamState) => {
|
|
1610
|
+
const task = teamState.tasks[taskId];
|
|
1611
|
+
if (!task || task.resultContract) {
|
|
1612
|
+
return;
|
|
1613
|
+
}
|
|
1614
|
+
task.resultContract = contract;
|
|
1615
|
+
});
|
|
1616
|
+
recordTaskExecutionEvent(taskId, {
|
|
1617
|
+
type: "lifecycle",
|
|
1618
|
+
phase: "result_contract_backfilled",
|
|
1619
|
+
source: "controller",
|
|
1620
|
+
message: "Worker did not submit a structured result contract; TeamClaw backfilled one from the recorded task result.",
|
|
1621
|
+
workerId: currentTask.assignedWorkerId,
|
|
1622
|
+
role: currentTask.assignedRole,
|
|
1623
|
+
}, deps);
|
|
1624
|
+
return contract;
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
function buildResultContractSection(task: TaskInfo): string {
|
|
1628
|
+
const contract = task.resultContract;
|
|
1629
|
+
if (!contract) {
|
|
1630
|
+
return "";
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
const lines = [
|
|
1634
|
+
"## Structured Result Contract",
|
|
1635
|
+
`Outcome: ${contract.outcome}`,
|
|
1636
|
+
`Summary: ${contract.summary}`,
|
|
1637
|
+
];
|
|
1638
|
+
if (contract.deliverables.length > 0) {
|
|
1639
|
+
lines.push("Deliverables:");
|
|
1640
|
+
for (const deliverable of contract.deliverables) {
|
|
1641
|
+
const summary = deliverable.summary ? ` — ${deliverable.summary}` : "";
|
|
1642
|
+
lines.push(`- ${deliverable.kind}: ${deliverable.value}${summary}`);
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
if (contract.keyPoints.length > 0) {
|
|
1646
|
+
lines.push("Key points:");
|
|
1647
|
+
for (const keyPoint of contract.keyPoints) {
|
|
1648
|
+
lines.push(`- ${keyPoint}`);
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
if (contract.blockers.length > 0) {
|
|
1652
|
+
lines.push("Blockers:");
|
|
1653
|
+
for (const blocker of contract.blockers) {
|
|
1654
|
+
lines.push(`- ${blocker}`);
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
if (contract.followUps.length > 0) {
|
|
1658
|
+
lines.push("Suggested follow-ups:");
|
|
1659
|
+
for (const followUp of contract.followUps) {
|
|
1660
|
+
const roleLabel = followUp.targetRole ? ` (${followUp.targetRole})` : "";
|
|
1661
|
+
lines.push(`- ${followUp.type}${roleLabel}: ${followUp.reason}`);
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
if (contract.questions.length > 0) {
|
|
1665
|
+
lines.push("Open questions:");
|
|
1666
|
+
for (const question of contract.questions) {
|
|
1667
|
+
lines.push(`- ${question}`);
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
if (contract.notes) {
|
|
1671
|
+
lines.push(`Notes: ${contract.notes}`);
|
|
1672
|
+
}
|
|
1673
|
+
return lines.join("\n");
|
|
1674
|
+
}
|
|
1675
|
+
|
|
946
1676
|
function revertTaskAssignment(taskId: string, workerId: string, deps: ControllerHttpDeps): TaskInfo | undefined {
|
|
947
1677
|
const { updateTeamState, wsServer } = deps;
|
|
948
1678
|
let revertEvent: TaskExecutionEvent | undefined;
|
|
@@ -967,7 +1697,9 @@ function revertTaskAssignment(taskId: string, workerId: string, deps: Controller
|
|
|
967
1697
|
|
|
968
1698
|
const worker = s.workers[workerId];
|
|
969
1699
|
if (worker?.currentTaskId === taskId) {
|
|
970
|
-
worker.status
|
|
1700
|
+
if (worker.status !== "offline") {
|
|
1701
|
+
worker.status = "idle";
|
|
1702
|
+
}
|
|
971
1703
|
worker.currentTaskId = undefined;
|
|
972
1704
|
}
|
|
973
1705
|
});
|
|
@@ -1054,12 +1786,15 @@ async function dispatchTaskToWorker(
|
|
|
1054
1786
|
const repoInfo = buildRepoSyncInfo(repoState, sharedWorkspace);
|
|
1055
1787
|
const description = buildTaskAssignmentDescription(task, state ?? null, repoInfo);
|
|
1056
1788
|
const recommendedSkills = resolveRecommendedSkillsForRole(task.assignedRole, task.recommendedSkills ?? []);
|
|
1789
|
+
const executionIdentity = buildTaskExecutionIdentity(task.id, worker.id);
|
|
1057
1790
|
const assignment: TaskAssignmentPayload = {
|
|
1058
1791
|
taskId: task.id,
|
|
1059
1792
|
title: task.title,
|
|
1060
1793
|
description,
|
|
1061
1794
|
priority: task.priority,
|
|
1062
1795
|
recommendedSkills,
|
|
1796
|
+
executionSessionKey: executionIdentity.executionSessionKey,
|
|
1797
|
+
executionIdempotencyKey: executionIdentity.executionIdempotencyKey,
|
|
1063
1798
|
repo: repoInfo,
|
|
1064
1799
|
};
|
|
1065
1800
|
|
|
@@ -1115,6 +1850,7 @@ async function assignTaskToWorker(
|
|
|
1115
1850
|
if (options?.assignedRole) {
|
|
1116
1851
|
task.assignedRole = options.assignedRole;
|
|
1117
1852
|
}
|
|
1853
|
+
resetTaskForFreshAttempt(task);
|
|
1118
1854
|
task.updatedAt = Date.now();
|
|
1119
1855
|
|
|
1120
1856
|
targetWorker.status = "busy";
|
|
@@ -1241,6 +1977,13 @@ async function handleRequest(
|
|
|
1241
1977
|
const requestUrl = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
1242
1978
|
|
|
1243
1979
|
// ==================== Web UI ====================
|
|
1980
|
+
if (req.method === "GET" && pathname === "/") {
|
|
1981
|
+
res.statusCode = 302;
|
|
1982
|
+
res.setHeader("Location", "/ui");
|
|
1983
|
+
res.end();
|
|
1984
|
+
return;
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1244
1987
|
if (req.method === "GET" && (pathname === "/ui" || pathname === "/ui/")) {
|
|
1245
1988
|
const uiPath = path.join(import.meta.dirname, "..", "ui");
|
|
1246
1989
|
serveStaticFile(res, path.join(uiPath, "index.html"), "text/html; charset=utf-8");
|
|
@@ -1386,6 +2129,7 @@ async function handleRequest(
|
|
|
1386
2129
|
) {
|
|
1387
2130
|
task.status = "pending";
|
|
1388
2131
|
task.assignedWorkerId = undefined;
|
|
2132
|
+
resetTaskForFreshAttempt(task);
|
|
1389
2133
|
task.updatedAt = Date.now();
|
|
1390
2134
|
affectedTaskIds.push(task.id);
|
|
1391
2135
|
}
|
|
@@ -1448,6 +2192,9 @@ async function handleRequest(
|
|
|
1448
2192
|
const priority = typeof body.priority === "string" ? body.priority as TaskPriority : "medium";
|
|
1449
2193
|
const assignedRole = typeof body.assignedRole === "string" ? body.assignedRole as RoleId : undefined;
|
|
1450
2194
|
const createdBy = typeof body.createdBy === "string" ? body.createdBy : "boss";
|
|
2195
|
+
const controllerSessionKey = createdBy === "controller" && typeof body.controllerSessionKey === "string" && body.controllerSessionKey.trim()
|
|
2196
|
+
? normalizeControllerIntakeSessionKey(body.controllerSessionKey)
|
|
2197
|
+
: undefined;
|
|
1451
2198
|
const recommendedSkills = normalizeRecommendedSkills(
|
|
1452
2199
|
Array.isArray(body.recommendedSkills) ? body.recommendedSkills.map((entry) => String(entry ?? "")) : [],
|
|
1453
2200
|
);
|
|
@@ -1456,6 +2203,10 @@ async function handleRequest(
|
|
|
1456
2203
|
sendError(res, 400, "title is required");
|
|
1457
2204
|
return;
|
|
1458
2205
|
}
|
|
2206
|
+
if (createdBy === "controller" && shouldBlockControllerWithoutWorkers(deps.config, getTeamState())) {
|
|
2207
|
+
sendError(res, 409, buildControllerNoWorkersMessage());
|
|
2208
|
+
return;
|
|
2209
|
+
}
|
|
1459
2210
|
|
|
1460
2211
|
const taskId = generateId();
|
|
1461
2212
|
const now = Date.now();
|
|
@@ -1470,6 +2221,7 @@ async function handleRequest(
|
|
|
1470
2221
|
assignedRole,
|
|
1471
2222
|
createdBy,
|
|
1472
2223
|
recommendedSkills: recommendedSkills.length > 0 ? recommendedSkills : undefined,
|
|
2224
|
+
controllerSessionKey,
|
|
1473
2225
|
createdAt: now,
|
|
1474
2226
|
updatedAt: now,
|
|
1475
2227
|
};
|
|
@@ -1572,12 +2324,14 @@ async function handleRequest(
|
|
|
1572
2324
|
const body = await parseJsonBody(req);
|
|
1573
2325
|
let statusEvent: TaskExecutionEvent | undefined;
|
|
1574
2326
|
let progressEvent: TaskExecutionEvent | undefined;
|
|
2327
|
+
let progressContract: WorkerProgressContract | undefined;
|
|
1575
2328
|
|
|
1576
2329
|
const state = updateTeamState((s) => {
|
|
1577
2330
|
const task = s.tasks[taskId];
|
|
1578
2331
|
if (!task) return;
|
|
1579
2332
|
const previousStatus = task.status;
|
|
1580
2333
|
const previousProgress = task.progress;
|
|
2334
|
+
const previousProgressContract = task.progressContract;
|
|
1581
2335
|
if (typeof body.status === "string") task.status = body.status as TaskStatus;
|
|
1582
2336
|
if (typeof body.progress === "string") task.progress = body.progress as string;
|
|
1583
2337
|
if (typeof body.priority === "string") task.priority = body.priority as TaskPriority;
|
|
@@ -1588,6 +2342,12 @@ async function handleRequest(
|
|
|
1588
2342
|
);
|
|
1589
2343
|
task.recommendedSkills = recommendedSkills.length > 0 ? recommendedSkills : undefined;
|
|
1590
2344
|
}
|
|
2345
|
+
progressContract = normalizeWorkerProgressContract(body.progressContract)
|
|
2346
|
+
?? (typeof body.progress === "string" ? backfillWorkerProgressContract(body.progress, typeof body.status === "string" ? body.status : undefined) : undefined);
|
|
2347
|
+
if (progressContract) {
|
|
2348
|
+
task.progressContract = progressContract;
|
|
2349
|
+
task.progress = task.progress || progressContract.summary;
|
|
2350
|
+
}
|
|
1591
2351
|
task.updatedAt = Date.now();
|
|
1592
2352
|
|
|
1593
2353
|
if (typeof body.status === "string" && body.status !== previousStatus) {
|
|
@@ -1607,6 +2367,14 @@ async function handleRequest(
|
|
|
1607
2367
|
status: task.status === "in_progress" || task.status === "review" ? "running" : undefined,
|
|
1608
2368
|
message: body.progress as string,
|
|
1609
2369
|
});
|
|
2370
|
+
} else if (progressContract && JSON.stringify(progressContract) !== JSON.stringify(previousProgressContract)) {
|
|
2371
|
+
progressEvent = appendTaskExecutionEvent(task, {
|
|
2372
|
+
type: "progress",
|
|
2373
|
+
phase: "progress_contract_reported",
|
|
2374
|
+
source: "worker",
|
|
2375
|
+
status: task.status === "in_progress" || task.status === "review" ? "running" : undefined,
|
|
2376
|
+
message: progressContract.summary,
|
|
2377
|
+
});
|
|
1610
2378
|
}
|
|
1611
2379
|
});
|
|
1612
2380
|
|
|
@@ -1665,6 +2433,13 @@ async function handleRequest(
|
|
|
1665
2433
|
const taskId = pathname.split("/")[4]!;
|
|
1666
2434
|
const body = await parseJsonBody(req);
|
|
1667
2435
|
const targetRole = typeof body.targetRole === "string" ? body.targetRole as RoleId : undefined;
|
|
2436
|
+
const handoffContract = normalizeTaskHandoffContract(body.contract, {
|
|
2437
|
+
targetRole,
|
|
2438
|
+
reason: typeof body.reason === "string" ? body.reason : targetRole ? `The next step should move to ${targetRole}.` : "The task needs a new assignee.",
|
|
2439
|
+
summary: typeof body.summary === "string" ? body.summary : undefined,
|
|
2440
|
+
expectedNextStep: typeof body.expectedNextStep === "string" ? body.expectedNextStep : undefined,
|
|
2441
|
+
artifacts: [],
|
|
2442
|
+
});
|
|
1668
2443
|
|
|
1669
2444
|
const state = getTeamState();
|
|
1670
2445
|
if (!state?.tasks[taskId]) {
|
|
@@ -1673,27 +2448,51 @@ async function handleRequest(
|
|
|
1673
2448
|
}
|
|
1674
2449
|
|
|
1675
2450
|
const previousWorkerId = state.tasks[taskId].assignedWorkerId;
|
|
2451
|
+
const avoidPreviousManagedWorker = Boolean(
|
|
2452
|
+
previousWorkerId && deps.workerProvisioningManager?.hasManagedWorker(previousWorkerId),
|
|
2453
|
+
);
|
|
1676
2454
|
|
|
1677
2455
|
updateTeamState((s) => {
|
|
1678
|
-
s.tasks[taskId]
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
2456
|
+
const task = s.tasks[taskId];
|
|
2457
|
+
task.status = "pending";
|
|
2458
|
+
task.assignedWorkerId = undefined;
|
|
2459
|
+
task.assignedRole = targetRole ?? task.assignedRole;
|
|
2460
|
+
task.lastHandoff = handoffContract;
|
|
2461
|
+
resetTaskForFreshAttempt(task);
|
|
2462
|
+
task.updatedAt = Date.now();
|
|
1682
2463
|
|
|
1683
2464
|
// Free old worker
|
|
1684
2465
|
if (previousWorkerId && s.workers[previousWorkerId]) {
|
|
1685
|
-
s.workers[previousWorkerId]
|
|
1686
|
-
|
|
2466
|
+
const previousWorker = s.workers[previousWorkerId];
|
|
2467
|
+
if (previousWorker.status !== "offline") {
|
|
2468
|
+
previousWorker.status = "idle";
|
|
2469
|
+
}
|
|
2470
|
+
previousWorker.currentTaskId = undefined;
|
|
1687
2471
|
}
|
|
1688
2472
|
});
|
|
1689
2473
|
|
|
1690
2474
|
await cancelTaskExecution(taskId, previousWorkerId, "handoff", deps);
|
|
2475
|
+
if (avoidPreviousManagedWorker && previousWorkerId) {
|
|
2476
|
+
try {
|
|
2477
|
+
await deps.workerProvisioningManager?.onWorkerRemoved(
|
|
2478
|
+
previousWorkerId,
|
|
2479
|
+
`handoff for ${taskId} requested a fresh managed worker`,
|
|
2480
|
+
);
|
|
2481
|
+
} catch (err) {
|
|
2482
|
+
logger.warn(`Controller: failed to retire previous managed worker ${previousWorkerId}: ${String(err)}`);
|
|
2483
|
+
}
|
|
2484
|
+
}
|
|
1691
2485
|
|
|
1692
2486
|
// Try auto-assign to new role
|
|
1693
2487
|
const newState = getTeamState()!;
|
|
1694
|
-
const
|
|
2488
|
+
const routingWorkers = avoidPreviousManagedWorker && previousWorkerId
|
|
2489
|
+
? Object.fromEntries(Object.entries(newState.workers).filter(([workerId]) => workerId !== previousWorkerId))
|
|
2490
|
+
: newState.workers;
|
|
2491
|
+
const worker = taskRouter.routeTask(newState.tasks[taskId], routingWorkers);
|
|
1695
2492
|
if (worker) {
|
|
1696
2493
|
await assignTaskToWorker(taskId, worker, deps, { assignedRole: targetRole });
|
|
2494
|
+
} else {
|
|
2495
|
+
scheduleProvisioningReconcile(deps, `handoff:${taskId}`);
|
|
1697
2496
|
}
|
|
1698
2497
|
|
|
1699
2498
|
const updatedTask = getTeamState()?.tasks[taskId];
|
|
@@ -1702,8 +2501,8 @@ async function handleRequest(
|
|
|
1702
2501
|
phase: "handoff",
|
|
1703
2502
|
source: "controller",
|
|
1704
2503
|
message: targetRole
|
|
1705
|
-
? `Task handed off and re-routed to role ${targetRole}
|
|
1706
|
-
:
|
|
2504
|
+
? `Task handed off and re-routed to role ${targetRole}: ${handoffContract.summary}`
|
|
2505
|
+
: `Task handed off for re-routing: ${handoffContract.summary}`,
|
|
1707
2506
|
role: targetRole,
|
|
1708
2507
|
}, deps);
|
|
1709
2508
|
wsServer.broadcastUpdate({ type: "task:updated", data: serializeTask(updatedTask) });
|
|
@@ -1711,6 +2510,48 @@ async function handleRequest(
|
|
|
1711
2510
|
return;
|
|
1712
2511
|
}
|
|
1713
2512
|
|
|
2513
|
+
// POST /api/v1/tasks/:id/result-contract
|
|
2514
|
+
if (req.method === "POST" && pathname.match(/^\/api\/v1\/tasks\/[^/]+\/result-contract$/)) {
|
|
2515
|
+
const taskId = pathname.split("/")[4]!;
|
|
2516
|
+
const body = await parseJsonBody(req);
|
|
2517
|
+
const contract = normalizeWorkerTaskResultContract(body.contract ?? body.resultContract);
|
|
2518
|
+
const workerId = typeof body.workerId === "string" ? body.workerId : undefined;
|
|
2519
|
+
const currentTask = getTeamState()?.tasks[taskId];
|
|
2520
|
+
if (!currentTask) {
|
|
2521
|
+
sendError(res, 404, "Task not found");
|
|
2522
|
+
return;
|
|
2523
|
+
}
|
|
2524
|
+
if (!contract) {
|
|
2525
|
+
sendError(res, 400, "result contract is required");
|
|
2526
|
+
return;
|
|
2527
|
+
}
|
|
2528
|
+
if (workerId && !canAcceptWorkerUpdate(currentTask, workerId)) {
|
|
2529
|
+
logger.info(`Controller: ignoring stale result contract for ${taskId} from ${workerId}`);
|
|
2530
|
+
sendJson(res, 202, { status: "ignored", reason: "stale-worker-result-contract" });
|
|
2531
|
+
return;
|
|
2532
|
+
}
|
|
2533
|
+
|
|
2534
|
+
const state = updateTeamState((teamState) => {
|
|
2535
|
+
const task = teamState.tasks[taskId];
|
|
2536
|
+
if (!task) {
|
|
2537
|
+
return;
|
|
2538
|
+
}
|
|
2539
|
+
task.resultContract = contract;
|
|
2540
|
+
task.updatedAt = Date.now();
|
|
2541
|
+
});
|
|
2542
|
+
recordTaskExecutionEvent(taskId, {
|
|
2543
|
+
type: "output",
|
|
2544
|
+
phase: "result_contract_recorded",
|
|
2545
|
+
source: "worker",
|
|
2546
|
+
status: contract.outcome === "failed" ? "failed" : "running",
|
|
2547
|
+
message: contract.summary,
|
|
2548
|
+
workerId,
|
|
2549
|
+
role: currentTask.assignedRole,
|
|
2550
|
+
}, deps);
|
|
2551
|
+
sendJson(res, 201, { task: serializeTask(state.tasks[taskId]) });
|
|
2552
|
+
return;
|
|
2553
|
+
}
|
|
2554
|
+
|
|
1714
2555
|
// POST /api/v1/tasks/:id/result
|
|
1715
2556
|
if (req.method === "POST" && pathname.match(/^\/api\/v1\/tasks\/[^/]+\/result$/)) {
|
|
1716
2557
|
const taskId = pathname.split("/")[4]!;
|
|
@@ -1730,11 +2571,32 @@ async function handleRequest(
|
|
|
1730
2571
|
}
|
|
1731
2572
|
const previousWorkerId = getTeamState()?.tasks[taskId]?.assignedWorkerId;
|
|
1732
2573
|
|
|
2574
|
+
const submittedContract = normalizeWorkerTaskResultContract(body.contract ?? body.resultContract);
|
|
2575
|
+
if (submittedContract) {
|
|
2576
|
+
updateTeamState((teamState) => {
|
|
2577
|
+
const task = teamState.tasks[taskId];
|
|
2578
|
+
if (!task) {
|
|
2579
|
+
return;
|
|
2580
|
+
}
|
|
2581
|
+
task.resultContract = submittedContract;
|
|
2582
|
+
});
|
|
2583
|
+
recordTaskExecutionEvent(taskId, {
|
|
2584
|
+
type: "output",
|
|
2585
|
+
phase: "result_contract_recorded",
|
|
2586
|
+
source: "worker",
|
|
2587
|
+
status: error ? "failed" : "running",
|
|
2588
|
+
message: submittedContract.summary,
|
|
2589
|
+
workerId,
|
|
2590
|
+
role: currentTask.assignedRole,
|
|
2591
|
+
}, deps);
|
|
2592
|
+
}
|
|
2593
|
+
|
|
1733
2594
|
const updatedTask = applyTaskResult(taskId, result, error, deps);
|
|
2595
|
+
ensureTaskResultContract(taskId, result, error, deps);
|
|
1734
2596
|
if (!workerId || workerId !== previousWorkerId) {
|
|
1735
2597
|
await cancelTaskExecution(taskId, previousWorkerId, "manual result submission", deps);
|
|
1736
2598
|
}
|
|
1737
|
-
sendJson(res, 200, { task: serializeTask(updatedTask) });
|
|
2599
|
+
sendJson(res, 200, { task: serializeTask(getTeamState()?.tasks[taskId] ?? updatedTask) });
|
|
1738
2600
|
return;
|
|
1739
2601
|
}
|
|
1740
2602
|
|
|
@@ -1792,6 +2654,48 @@ async function handleRequest(
|
|
|
1792
2654
|
|
|
1793
2655
|
// ==================== Message Routing ====================
|
|
1794
2656
|
|
|
2657
|
+
// POST /api/v1/controller/manifest
|
|
2658
|
+
if (req.method === "POST" && pathname === "/api/v1/controller/manifest") {
|
|
2659
|
+
const body = await parseJsonBody(req);
|
|
2660
|
+
const sessionKey = normalizeControllerIntakeSessionKey(body.sessionKey);
|
|
2661
|
+
const manifest = normalizeControllerManifest(body.manifest);
|
|
2662
|
+
if (!manifest) {
|
|
2663
|
+
sendError(res, 400, "manifest is required and must include requirementSummary");
|
|
2664
|
+
return;
|
|
2665
|
+
}
|
|
2666
|
+
|
|
2667
|
+
const runId = findLatestControllerRunIdForSession(sessionKey, deps.getTeamState(), {
|
|
2668
|
+
preferActive: true,
|
|
2669
|
+
});
|
|
2670
|
+
if (!runId) {
|
|
2671
|
+
sendError(res, 404, "Controller run not found for session");
|
|
2672
|
+
return;
|
|
2673
|
+
}
|
|
2674
|
+
|
|
2675
|
+
const updatedRun = updateControllerRun(runId, deps, (run) => {
|
|
2676
|
+
run.manifest = manifest;
|
|
2677
|
+
appendControllerRunEvent(run, {
|
|
2678
|
+
type: "output",
|
|
2679
|
+
phase: "manifest_recorded",
|
|
2680
|
+
source: "controller",
|
|
2681
|
+
status: "running",
|
|
2682
|
+
sessionKey,
|
|
2683
|
+
message: buildControllerManifestEventMessage(manifest),
|
|
2684
|
+
});
|
|
2685
|
+
});
|
|
2686
|
+
|
|
2687
|
+
if (!updatedRun) {
|
|
2688
|
+
sendError(res, 404, "Controller run not found");
|
|
2689
|
+
return;
|
|
2690
|
+
}
|
|
2691
|
+
|
|
2692
|
+
sendJson(res, 201, {
|
|
2693
|
+
controllerRun: serializeControllerRun(updatedRun),
|
|
2694
|
+
manifest,
|
|
2695
|
+
});
|
|
2696
|
+
return;
|
|
2697
|
+
}
|
|
2698
|
+
|
|
1795
2699
|
// GET /api/v1/controller/runs
|
|
1796
2700
|
if (req.method === "GET" && pathname === "/api/v1/controller/runs") {
|
|
1797
2701
|
const state = getTeamState();
|
|
@@ -1836,6 +2740,12 @@ async function handleRequest(
|
|
|
1836
2740
|
toRole: typeof body.toRole === "string" ? body.toRole as RoleId : undefined,
|
|
1837
2741
|
type: "direct",
|
|
1838
2742
|
content: typeof body.content === "string" ? body.content : "",
|
|
2743
|
+
contract: ensureTeamMessageContract(body.contract, {
|
|
2744
|
+
type: "direct",
|
|
2745
|
+
content: typeof body.content === "string" ? body.content : "",
|
|
2746
|
+
toRole: typeof body.toRole === "string" ? body.toRole as RoleId : undefined,
|
|
2747
|
+
taskId: typeof body.taskId === "string" ? body.taskId : undefined,
|
|
2748
|
+
}),
|
|
1839
2749
|
taskId: typeof body.taskId === "string" ? body.taskId : undefined,
|
|
1840
2750
|
createdAt: Date.now(),
|
|
1841
2751
|
};
|
|
@@ -1858,6 +2768,11 @@ async function handleRequest(
|
|
|
1858
2768
|
fromRole: typeof body.fromRole === "string" ? body.fromRole as RoleId : undefined,
|
|
1859
2769
|
type: "broadcast",
|
|
1860
2770
|
content: typeof body.content === "string" ? body.content : "",
|
|
2771
|
+
contract: ensureTeamMessageContract(body.contract, {
|
|
2772
|
+
type: "broadcast",
|
|
2773
|
+
content: typeof body.content === "string" ? body.content : "",
|
|
2774
|
+
taskId: typeof body.taskId === "string" ? body.taskId : undefined,
|
|
2775
|
+
}),
|
|
1861
2776
|
taskId: typeof body.taskId === "string" ? body.taskId : undefined,
|
|
1862
2777
|
createdAt: Date.now(),
|
|
1863
2778
|
};
|
|
@@ -1889,6 +2804,14 @@ async function handleRequest(
|
|
|
1889
2804
|
toRole: typeof body.toRole === "string" ? body.toRole as RoleId : undefined,
|
|
1890
2805
|
type: "review-request",
|
|
1891
2806
|
content: typeof body.content === "string" ? body.content : "",
|
|
2807
|
+
contract: ensureTeamMessageContract(body.contract, {
|
|
2808
|
+
type: "review-request",
|
|
2809
|
+
content: typeof body.content === "string" ? body.content : "",
|
|
2810
|
+
toRole: typeof body.toRole === "string" ? body.toRole as RoleId : undefined,
|
|
2811
|
+
taskId: typeof body.taskId === "string" ? body.taskId : undefined,
|
|
2812
|
+
intent: "review-request",
|
|
2813
|
+
needsResponse: true,
|
|
2814
|
+
}),
|
|
1892
2815
|
taskId: typeof body.taskId === "string" ? body.taskId : undefined,
|
|
1893
2816
|
createdAt: Date.now(),
|
|
1894
2817
|
};
|
|
@@ -1994,8 +2917,11 @@ async function handleRequest(
|
|
|
1994
2917
|
task.updatedAt = now;
|
|
1995
2918
|
|
|
1996
2919
|
if (assignedWorkerId && s.workers[assignedWorkerId]) {
|
|
1997
|
-
s.workers[assignedWorkerId]
|
|
1998
|
-
|
|
2920
|
+
const assignedWorker = s.workers[assignedWorkerId];
|
|
2921
|
+
if (assignedWorker.status !== "offline") {
|
|
2922
|
+
assignedWorker.status = "idle";
|
|
2923
|
+
}
|
|
2924
|
+
assignedWorker.currentTaskId = undefined;
|
|
1999
2925
|
}
|
|
2000
2926
|
});
|
|
2001
2927
|
|
|
@@ -2078,6 +3004,7 @@ async function handleRequest(
|
|
|
2078
3004
|
task.status = "pending";
|
|
2079
3005
|
task.progress = `Clarification answered by ${answeredBy}: ${answer}`;
|
|
2080
3006
|
task.clarificationRequestId = undefined;
|
|
3007
|
+
resetTaskForFreshAttempt(task);
|
|
2081
3008
|
task.updatedAt = now;
|
|
2082
3009
|
});
|
|
2083
3010
|
|
|
@@ -2092,6 +3019,17 @@ async function handleRequest(
|
|
|
2092
3019
|
toRole: clarification.requestedByRole,
|
|
2093
3020
|
type: "direct",
|
|
2094
3021
|
content: `Clarification answer for task ${task.id}: ${answer}`,
|
|
3022
|
+
contract: ensureTeamMessageContract(null, {
|
|
3023
|
+
type: "direct",
|
|
3024
|
+
content: `Clarification answer for task ${task.id}: ${answer}`,
|
|
3025
|
+
toRole: clarification.requestedByRole,
|
|
3026
|
+
taskId: task.id,
|
|
3027
|
+
summary: `Clarification answered for task ${task.id}`,
|
|
3028
|
+
details: answer,
|
|
3029
|
+
requestedAction: "Resume the task using this clarification.",
|
|
3030
|
+
needsResponse: false,
|
|
3031
|
+
intent: "update",
|
|
3032
|
+
}),
|
|
2095
3033
|
taskId: task.id,
|
|
2096
3034
|
createdAt: now,
|
|
2097
3035
|
};
|