@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.
@@ -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 || !/^[a-zA-Z0-9:_-]{1,120}$/.test(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
- return trimmed.startsWith(CONTROLLER_INTAKE_SESSION_PREFIX)
432
- ? trimmed
433
- : `${CONTROLLER_INTAKE_SESSION_PREFIX}${trimmed}`;
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" || task.controllerSessionKey) {
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" || !task.controllerSessionKey) {
953
+ if (task.createdBy !== "controller") {
494
954
  return;
495
955
  }
496
- await runControllerIntake(buildControllerFollowUpMessage(task), task.controllerSessionKey, deps, {
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 waitResult = await deps.runtime.subagent.waitForRun({
540
- runId: runResult.runId,
541
- timeoutMs: Math.min(deps.config.taskTimeoutMs, CONTROLLER_INTAKE_TIMEOUT_CAP_MS),
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 sessionMessages = await deps.runtime.subagent.getSessionMessages({
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 = 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 (createdTaskIds.length > 0) {
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 created ${createdTaskIds.length} task(s): ${createdTaskIds.join(", ")}`,
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 raw = task.result || task.progress || lastExecutionMessage || task.description || "";
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].status = "idle";
915
- s.workers[task.assignedWorkerId].currentTaskId = undefined;
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" && updatedTask.controllerSessionKey) {
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 = "idle";
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].status = "pending";
1679
- s.tasks[taskId].assignedWorkerId = undefined;
1680
- s.tasks[taskId].assignedRole = targetRole ?? s.tasks[taskId].assignedRole;
1681
- s.tasks[taskId].updatedAt = Date.now();
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].status = "idle";
1686
- s.workers[previousWorkerId].currentTaskId = undefined;
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 worker = taskRouter.routeTask(newState.tasks[taskId], newState.workers);
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
- : "Task handed off for re-routing.",
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].status = "idle";
1998
- s.workers[assignedWorkerId].currentTaskId = undefined;
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
  };