@mcoda/core 0.1.8 → 0.1.11
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/CHANGELOG.md +3 -0
- package/README.md +2 -2
- package/dist/api/AgentsApi.d.ts +9 -1
- package/dist/api/AgentsApi.d.ts.map +1 -1
- package/dist/api/AgentsApi.js +201 -6
- package/dist/api/QaTasksApi.d.ts.map +1 -1
- package/dist/api/QaTasksApi.js +6 -0
- package/dist/api/TasksApi.d.ts.map +1 -1
- package/dist/api/TasksApi.js +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/prompts/PdrPrompts.d.ts.map +1 -1
- package/dist/prompts/PdrPrompts.js +9 -1
- package/dist/prompts/SdsPrompts.d.ts.map +1 -1
- package/dist/prompts/SdsPrompts.js +9 -0
- package/dist/services/agents/AgentRatingFormula.d.ts +27 -0
- package/dist/services/agents/AgentRatingFormula.d.ts.map +1 -0
- package/dist/services/agents/AgentRatingFormula.js +45 -0
- package/dist/services/agents/AgentRatingService.d.ts +60 -0
- package/dist/services/agents/AgentRatingService.d.ts.map +1 -0
- package/dist/services/agents/AgentRatingService.js +363 -0
- package/dist/services/agents/GatewayAgentService.d.ts +11 -0
- package/dist/services/agents/GatewayAgentService.d.ts.map +1 -1
- package/dist/services/agents/GatewayAgentService.js +525 -84
- package/dist/services/agents/GatewayHandoff.d.ts +11 -0
- package/dist/services/agents/GatewayHandoff.d.ts.map +1 -0
- package/dist/services/agents/GatewayHandoff.js +141 -0
- package/dist/services/agents/RoutingService.d.ts +1 -0
- package/dist/services/agents/RoutingService.d.ts.map +1 -1
- package/dist/services/agents/RoutingService.js +4 -4
- package/dist/services/backlog/BacklogService.d.ts +23 -0
- package/dist/services/backlog/BacklogService.d.ts.map +1 -1
- package/dist/services/backlog/BacklogService.js +62 -7
- package/dist/services/backlog/TaskOrderingHeuristics.d.ts +12 -0
- package/dist/services/backlog/TaskOrderingHeuristics.d.ts.map +1 -0
- package/dist/services/backlog/TaskOrderingHeuristics.js +56 -0
- package/dist/services/backlog/TaskOrderingService.d.ts +17 -4
- package/dist/services/backlog/TaskOrderingService.d.ts.map +1 -1
- package/dist/services/backlog/TaskOrderingService.js +538 -79
- package/dist/services/docs/DocInventory.d.ts +11 -0
- package/dist/services/docs/DocInventory.d.ts.map +1 -0
- package/dist/services/docs/DocInventory.js +230 -0
- package/dist/services/docs/DocgenRunContext.d.ts +59 -0
- package/dist/services/docs/DocgenRunContext.d.ts.map +1 -0
- package/dist/services/docs/DocgenRunContext.js +4 -0
- package/dist/services/docs/DocsService.d.ts +70 -3
- package/dist/services/docs/DocsService.d.ts.map +1 -1
- package/dist/services/docs/DocsService.js +1930 -89
- package/dist/services/docs/alignment/DocAlignmentGraph.d.ts +23 -0
- package/dist/services/docs/alignment/DocAlignmentGraph.d.ts.map +1 -0
- package/dist/services/docs/alignment/DocAlignmentGraph.js +78 -0
- package/dist/services/docs/alignment/DocAlignmentPatcher.d.ts +19 -0
- package/dist/services/docs/alignment/DocAlignmentPatcher.d.ts.map +1 -0
- package/dist/services/docs/alignment/DocAlignmentPatcher.js +222 -0
- package/dist/services/docs/patch/DocPatchEngine.d.ts +57 -0
- package/dist/services/docs/patch/DocPatchEngine.d.ts.map +1 -0
- package/dist/services/docs/patch/DocPatchEngine.js +331 -0
- package/dist/services/docs/review/Glossary.d.ts +16 -0
- package/dist/services/docs/review/Glossary.d.ts.map +1 -0
- package/dist/services/docs/review/Glossary.js +47 -0
- package/dist/services/docs/review/ReviewReportRenderer.d.ts +3 -0
- package/dist/services/docs/review/ReviewReportRenderer.d.ts.map +1 -0
- package/dist/services/docs/review/ReviewReportRenderer.js +133 -0
- package/dist/services/docs/review/ReviewReportSchema.d.ts +39 -0
- package/dist/services/docs/review/ReviewReportSchema.d.ts.map +1 -0
- package/dist/services/docs/review/ReviewReportSchema.js +47 -0
- package/dist/services/docs/review/ReviewTypes.d.ts +76 -0
- package/dist/services/docs/review/ReviewTypes.d.ts.map +1 -0
- package/dist/services/docs/review/ReviewTypes.js +94 -0
- package/dist/services/docs/review/gates/AdminOpenApiSpecGate.d.ts +7 -0
- package/dist/services/docs/review/gates/AdminOpenApiSpecGate.d.ts.map +1 -0
- package/dist/services/docs/review/gates/AdminOpenApiSpecGate.js +93 -0
- package/dist/services/docs/review/gates/ApiPathConsistencyGate.d.ts +7 -0
- package/dist/services/docs/review/gates/ApiPathConsistencyGate.d.ts.map +1 -0
- package/dist/services/docs/review/gates/ApiPathConsistencyGate.js +308 -0
- package/dist/services/docs/review/gates/BuildReadyCompletenessGate.d.ts +8 -0
- package/dist/services/docs/review/gates/BuildReadyCompletenessGate.d.ts.map +1 -0
- package/dist/services/docs/review/gates/BuildReadyCompletenessGate.js +278 -0
- package/dist/services/docs/review/gates/DeploymentBlueprintGate.d.ts +8 -0
- package/dist/services/docs/review/gates/DeploymentBlueprintGate.d.ts.map +1 -0
- package/dist/services/docs/review/gates/DeploymentBlueprintGate.js +487 -0
- package/dist/services/docs/review/gates/NoMaybesGate.d.ts +8 -0
- package/dist/services/docs/review/gates/NoMaybesGate.d.ts.map +1 -0
- package/dist/services/docs/review/gates/NoMaybesGate.js +145 -0
- package/dist/services/docs/review/gates/OpenApiCoverageGate.d.ts +7 -0
- package/dist/services/docs/review/gates/OpenApiCoverageGate.d.ts.map +1 -0
- package/dist/services/docs/review/gates/OpenApiCoverageGate.js +266 -0
- package/dist/services/docs/review/gates/OpenApiSchemaSanityGate.d.ts +7 -0
- package/dist/services/docs/review/gates/OpenApiSchemaSanityGate.d.ts.map +1 -0
- package/dist/services/docs/review/gates/OpenApiSchemaSanityGate.js +59 -0
- package/dist/services/docs/review/gates/OpenQuestionsGate.d.ts +7 -0
- package/dist/services/docs/review/gates/OpenQuestionsGate.d.ts.map +1 -0
- package/dist/services/docs/review/gates/OpenQuestionsGate.js +200 -0
- package/dist/services/docs/review/gates/PdrInterfacesGate.d.ts +7 -0
- package/dist/services/docs/review/gates/PdrInterfacesGate.d.ts.map +1 -0
- package/dist/services/docs/review/gates/PdrInterfacesGate.js +159 -0
- package/dist/services/docs/review/gates/PdrOpenQuestionsGate.d.ts +8 -0
- package/dist/services/docs/review/gates/PdrOpenQuestionsGate.d.ts.map +1 -0
- package/dist/services/docs/review/gates/PdrOpenQuestionsGate.js +129 -0
- package/dist/services/docs/review/gates/PdrOwnershipGate.d.ts +7 -0
- package/dist/services/docs/review/gates/PdrOwnershipGate.d.ts.map +1 -0
- package/dist/services/docs/review/gates/PdrOwnershipGate.js +169 -0
- package/dist/services/docs/review/gates/PlaceholderArtifactGate.d.ts +10 -0
- package/dist/services/docs/review/gates/PlaceholderArtifactGate.d.ts.map +1 -0
- package/dist/services/docs/review/gates/PlaceholderArtifactGate.js +261 -0
- package/dist/services/docs/review/gates/RfpConsentGate.d.ts +6 -0
- package/dist/services/docs/review/gates/RfpConsentGate.d.ts.map +1 -0
- package/dist/services/docs/review/gates/RfpConsentGate.js +127 -0
- package/dist/services/docs/review/gates/RfpDefinitionGate.d.ts +7 -0
- package/dist/services/docs/review/gates/RfpDefinitionGate.d.ts.map +1 -0
- package/dist/services/docs/review/gates/RfpDefinitionGate.js +173 -0
- package/dist/services/docs/review/gates/SdsAdaptersGate.d.ts +7 -0
- package/dist/services/docs/review/gates/SdsAdaptersGate.d.ts.map +1 -0
- package/dist/services/docs/review/gates/SdsAdaptersGate.js +196 -0
- package/dist/services/docs/review/gates/SdsDecisionsGate.d.ts +7 -0
- package/dist/services/docs/review/gates/SdsDecisionsGate.d.ts.map +1 -0
- package/dist/services/docs/review/gates/SdsDecisionsGate.js +89 -0
- package/dist/services/docs/review/gates/SdsOpsGate.d.ts +7 -0
- package/dist/services/docs/review/gates/SdsOpsGate.d.ts.map +1 -0
- package/dist/services/docs/review/gates/SdsOpsGate.js +162 -0
- package/dist/services/docs/review/gates/SdsPolicyTelemetryGate.d.ts +7 -0
- package/dist/services/docs/review/gates/SdsPolicyTelemetryGate.d.ts.map +1 -0
- package/dist/services/docs/review/gates/SdsPolicyTelemetryGate.js +166 -0
- package/dist/services/docs/review/gates/SqlRequiredTablesGate.d.ts +7 -0
- package/dist/services/docs/review/gates/SqlRequiredTablesGate.d.ts.map +1 -0
- package/dist/services/docs/review/gates/SqlRequiredTablesGate.js +273 -0
- package/dist/services/docs/review/gates/SqlSyntaxGate.d.ts +7 -0
- package/dist/services/docs/review/gates/SqlSyntaxGate.d.ts.map +1 -0
- package/dist/services/docs/review/gates/SqlSyntaxGate.js +203 -0
- package/dist/services/docs/review/gates/TerminologyNormalizationGate.d.ts +9 -0
- package/dist/services/docs/review/gates/TerminologyNormalizationGate.d.ts.map +1 -0
- package/dist/services/docs/review/gates/TerminologyNormalizationGate.js +217 -0
- package/dist/services/docs/review/glossary.json +47 -0
- package/dist/services/estimate/EstimateService.d.ts +2 -0
- package/dist/services/estimate/EstimateService.d.ts.map +1 -1
- package/dist/services/estimate/EstimateService.js +66 -18
- package/dist/services/estimate/VelocityService.d.ts +4 -0
- package/dist/services/estimate/VelocityService.d.ts.map +1 -1
- package/dist/services/estimate/VelocityService.js +179 -36
- package/dist/services/estimate/types.d.ts +1 -0
- package/dist/services/estimate/types.d.ts.map +1 -1
- package/dist/services/execution/GatewayTrioService.d.ts +200 -0
- package/dist/services/execution/GatewayTrioService.d.ts.map +1 -0
- package/dist/services/execution/GatewayTrioService.js +2492 -0
- package/dist/services/execution/QaApiRunner.d.ts +30 -0
- package/dist/services/execution/QaApiRunner.d.ts.map +1 -0
- package/dist/services/execution/QaApiRunner.js +881 -0
- package/dist/services/execution/QaFollowupService.d.ts +2 -0
- package/dist/services/execution/QaFollowupService.d.ts.map +1 -1
- package/dist/services/execution/QaFollowupService.js +9 -2
- package/dist/services/execution/QaPlanValidator.d.ts +10 -0
- package/dist/services/execution/QaPlanValidator.d.ts.map +1 -0
- package/dist/services/execution/QaPlanValidator.js +128 -0
- package/dist/services/execution/QaProfileService.d.ts +27 -1
- package/dist/services/execution/QaProfileService.d.ts.map +1 -1
- package/dist/services/execution/QaProfileService.js +354 -7
- package/dist/services/execution/QaTasksService.d.ts +59 -1
- package/dist/services/execution/QaTasksService.d.ts.map +1 -1
- package/dist/services/execution/QaTasksService.js +3347 -318
- package/dist/services/execution/QaTestCommandBuilder.d.ts +51 -0
- package/dist/services/execution/QaTestCommandBuilder.d.ts.map +1 -0
- package/dist/services/execution/QaTestCommandBuilder.js +495 -0
- package/dist/services/execution/TaskSelectionService.d.ts +4 -2
- package/dist/services/execution/TaskSelectionService.d.ts.map +1 -1
- package/dist/services/execution/TaskSelectionService.js +144 -28
- package/dist/services/execution/TaskStateService.d.ts +19 -6
- package/dist/services/execution/TaskStateService.d.ts.map +1 -1
- package/dist/services/execution/TaskStateService.js +128 -13
- package/dist/services/execution/WorkOnTasksService.d.ts +32 -1
- package/dist/services/execution/WorkOnTasksService.d.ts.map +1 -1
- package/dist/services/execution/WorkOnTasksService.js +4667 -722
- package/dist/services/jobs/JobInsightsService.d.ts +4 -0
- package/dist/services/jobs/JobInsightsService.d.ts.map +1 -1
- package/dist/services/jobs/JobInsightsService.js +51 -5
- package/dist/services/jobs/JobResumeService.d.ts.map +1 -1
- package/dist/services/jobs/JobResumeService.js +23 -10
- package/dist/services/jobs/JobService.d.ts +56 -4
- package/dist/services/jobs/JobService.d.ts.map +1 -1
- package/dist/services/jobs/JobService.js +232 -1
- package/dist/services/openapi/OpenApiService.d.ts +51 -0
- package/dist/services/openapi/OpenApiService.d.ts.map +1 -1
- package/dist/services/openapi/OpenApiService.js +953 -106
- package/dist/services/planning/CreateTasksService.d.ts +21 -0
- package/dist/services/planning/CreateTasksService.d.ts.map +1 -1
- package/dist/services/planning/CreateTasksService.js +569 -31
- package/dist/services/planning/RefineTasksService.d.ts +9 -0
- package/dist/services/planning/RefineTasksService.d.ts.map +1 -1
- package/dist/services/planning/RefineTasksService.js +409 -59
- package/dist/services/review/CodeReviewService.d.ts +18 -0
- package/dist/services/review/CodeReviewService.d.ts.map +1 -1
- package/dist/services/review/CodeReviewService.js +1309 -167
- package/dist/services/review/ReviewNormalizer.d.ts +9 -0
- package/dist/services/review/ReviewNormalizer.d.ts.map +1 -0
- package/dist/services/review/ReviewNormalizer.js +147 -0
- package/dist/services/shared/AuthErrors.d.ts +3 -0
- package/dist/services/shared/AuthErrors.d.ts.map +1 -0
- package/dist/services/shared/AuthErrors.js +17 -0
- package/dist/services/shared/DocdexGuidance.d.ts +7 -0
- package/dist/services/shared/DocdexGuidance.d.ts.map +1 -0
- package/dist/services/shared/DocdexGuidance.js +12 -0
- package/dist/services/shared/ProjectGuidance.d.ts +17 -0
- package/dist/services/shared/ProjectGuidance.d.ts.map +1 -0
- package/dist/services/shared/ProjectGuidance.js +78 -0
- package/dist/services/system/ToolDenylist.d.ts +13 -0
- package/dist/services/system/ToolDenylist.d.ts.map +1 -0
- package/dist/services/system/ToolDenylist.js +85 -0
- package/dist/services/tasks/TaskCommentFormatter.d.ts +20 -0
- package/dist/services/tasks/TaskCommentFormatter.d.ts.map +1 -0
- package/dist/services/tasks/TaskCommentFormatter.js +54 -0
- package/dist/services/telemetry/TelemetryService.d.ts.map +1 -1
- package/dist/services/telemetry/TelemetryService.js +39 -7
- package/dist/workspace/WorkspaceManager.d.ts +26 -0
- package/dist/workspace/WorkspaceManager.d.ts.map +1 -1
- package/dist/workspace/WorkspaceManager.js +206 -32
- package/package.json +6 -5
|
@@ -0,0 +1,2492 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { WorkspaceRepository } from "@mcoda/db";
|
|
4
|
+
import { PathHelper, READY_TO_CODE_REVIEW, isReadyToReviewStatus, normalizeReviewStatuses, } from "@mcoda/shared";
|
|
5
|
+
import { readDocdexCheck, summarizeDocdexCheck } from "@mcoda/integrations";
|
|
6
|
+
import { GatewayAgentService } from "../agents/GatewayAgentService.js";
|
|
7
|
+
import { RoutingService } from "../agents/RoutingService.js";
|
|
8
|
+
import { buildGatewayHandoffContent, buildGatewayHandoffDocdexUsage, withGatewayHandoff, writeGatewayHandoffFile, } from "../agents/GatewayHandoff.js";
|
|
9
|
+
import { JobService } from "../jobs/JobService.js";
|
|
10
|
+
import { TaskSelectionService } from "./TaskSelectionService.js";
|
|
11
|
+
import { WorkOnTasksService } from "./WorkOnTasksService.js";
|
|
12
|
+
import { CodeReviewService } from "../review/CodeReviewService.js";
|
|
13
|
+
import { QaTasksService } from "./QaTasksService.js";
|
|
14
|
+
const DEFAULT_STATUS_FILTER = ["not_started", "in_progress", "changes_requested", READY_TO_CODE_REVIEW, "ready_to_qa"];
|
|
15
|
+
const TERMINAL_STATUSES = new Set(["completed", "cancelled", "failed"]);
|
|
16
|
+
const GATEWAY_FAILED_REASON = "gateway_failed";
|
|
17
|
+
const ESCALATION_REASONS = new Set([
|
|
18
|
+
"missing_patch",
|
|
19
|
+
"patch_failed",
|
|
20
|
+
"tests_failed",
|
|
21
|
+
"agent_timeout",
|
|
22
|
+
"review_invalid_output",
|
|
23
|
+
"work_status_not_ready",
|
|
24
|
+
]);
|
|
25
|
+
const NO_CHANGE_REASON = "no_changes";
|
|
26
|
+
const STRONG_TIER_MIN_COMPLEXITY = 5;
|
|
27
|
+
const SPECIALIST_TIER_MIN_COMPLEXITY = 8;
|
|
28
|
+
const DONE_DEPENDENCY_STATUSES = new Set(["completed", "cancelled"]);
|
|
29
|
+
const HEARTBEAT_INTERVAL_MS = 30000;
|
|
30
|
+
const ZERO_TOKEN_BACKOFF_MS = 750;
|
|
31
|
+
const PSEUDO_TASK_PREFIX = "[RUN]";
|
|
32
|
+
const ZERO_TOKEN_ERROR = "zero_tokens";
|
|
33
|
+
const FAILED_REOPEN_COOLDOWN_MS = 2 * 60 * 1000;
|
|
34
|
+
const MAX_FAILURE_REOPENS_PER_REASON = 2;
|
|
35
|
+
const AUTH_ERROR_REASON = "auth_error";
|
|
36
|
+
const AUTH_ERROR_PATTERNS = [
|
|
37
|
+
/auth_error/i,
|
|
38
|
+
/usage_limit_reached/i,
|
|
39
|
+
/too many requests/i,
|
|
40
|
+
/http\s*429/i,
|
|
41
|
+
/rate limit/i,
|
|
42
|
+
/usage limit/i,
|
|
43
|
+
];
|
|
44
|
+
const NON_RETRYABLE_FAILURE_REASONS = new Set([
|
|
45
|
+
"patch_failed",
|
|
46
|
+
"scope_violation",
|
|
47
|
+
"doc_edit_guard",
|
|
48
|
+
"merge_conflict",
|
|
49
|
+
"vcs_failed",
|
|
50
|
+
"task_lock_lost",
|
|
51
|
+
"missing_context",
|
|
52
|
+
"missing_docdex",
|
|
53
|
+
"review_invalid_output",
|
|
54
|
+
"gateway_invalid_output",
|
|
55
|
+
GATEWAY_FAILED_REASON,
|
|
56
|
+
AUTH_ERROR_REASON,
|
|
57
|
+
]);
|
|
58
|
+
const DOCDEX_SKIP_PATTERN = /\b(?:not executed|would run|not run|skipped)\b/i;
|
|
59
|
+
const DOCDEX_MISSING_PATTERN = /\b(?:docdex unavailable|docdex missing|no matching docs?|no matching documents?|no results|not provided)\b/i;
|
|
60
|
+
const MISSING_TASK_PATTERN = /\b(?:no concrete task|no task provided|task details are missing|task details missing|need the specific change request|no specific change request|no task context|task details are missing so no file paths can be named)\b/i;
|
|
61
|
+
const GUARDRAIL_REASON_PATTERN = /^guardrail:(retryable|non_retryable):([a-z0-9._-]+)$/i;
|
|
62
|
+
const GUARDRAIL_REASON_ALT_PATTERN = /^guardrail_(retryable|non_retryable):([a-z0-9._-]+)$/i;
|
|
63
|
+
const GOLDEN_EXAMPLES_REL_PATH = ".mcoda/codali/golden-examples.jsonl";
|
|
64
|
+
const GOLDEN_EXAMPLES_MAX = 50;
|
|
65
|
+
const DECISION_HISTORY_MAX = 30;
|
|
66
|
+
const normalizeFailureReason = (value) => {
|
|
67
|
+
if (!value)
|
|
68
|
+
return undefined;
|
|
69
|
+
const lower = value.toLowerCase();
|
|
70
|
+
if (lower === GATEWAY_FAILED_REASON || lower.startsWith(`${GATEWAY_FAILED_REASON}:`)) {
|
|
71
|
+
return GATEWAY_FAILED_REASON;
|
|
72
|
+
}
|
|
73
|
+
if (AUTH_ERROR_PATTERNS.some((pattern) => pattern.test(lower))) {
|
|
74
|
+
return AUTH_ERROR_REASON;
|
|
75
|
+
}
|
|
76
|
+
return lower.trim();
|
|
77
|
+
};
|
|
78
|
+
const parseGuardrailReason = (value) => {
|
|
79
|
+
const raw = value?.trim();
|
|
80
|
+
if (!raw)
|
|
81
|
+
return undefined;
|
|
82
|
+
const match = GUARDRAIL_REASON_PATTERN.exec(raw) ?? GUARDRAIL_REASON_ALT_PATTERN.exec(raw);
|
|
83
|
+
if (!match)
|
|
84
|
+
return undefined;
|
|
85
|
+
const disposition = match[1].toLowerCase();
|
|
86
|
+
const reason = normalizeFailureReason(match[2]) ?? match[2].toLowerCase();
|
|
87
|
+
if (!reason)
|
|
88
|
+
return undefined;
|
|
89
|
+
return {
|
|
90
|
+
retryable: disposition === "retryable",
|
|
91
|
+
reason,
|
|
92
|
+
};
|
|
93
|
+
};
|
|
94
|
+
export class GatewayTrioService {
|
|
95
|
+
constructor(workspace, deps) {
|
|
96
|
+
this.workspace = workspace;
|
|
97
|
+
this.deps = deps;
|
|
98
|
+
this.projectKeyCache = new Map();
|
|
99
|
+
this.selectionService =
|
|
100
|
+
deps.selectionService ?? new TaskSelectionService(workspace, deps.workspaceRepo);
|
|
101
|
+
}
|
|
102
|
+
static async create(workspace, options = {}) {
|
|
103
|
+
const workspaceRepo = await WorkspaceRepository.create(workspace.workspaceRoot);
|
|
104
|
+
const jobService = new JobService(workspace, workspaceRepo);
|
|
105
|
+
const gatewayService = await GatewayAgentService.create(workspace);
|
|
106
|
+
const routingService = await RoutingService.create();
|
|
107
|
+
const workService = await WorkOnTasksService.create(workspace);
|
|
108
|
+
const reviewService = await CodeReviewService.create(workspace);
|
|
109
|
+
const qaService = await QaTasksService.create(workspace, { noTelemetry: options.noTelemetry ?? false });
|
|
110
|
+
const selectionService = new TaskSelectionService(workspace, workspaceRepo);
|
|
111
|
+
return new GatewayTrioService(workspace, {
|
|
112
|
+
workspaceRepo,
|
|
113
|
+
jobService,
|
|
114
|
+
gatewayService,
|
|
115
|
+
routingService,
|
|
116
|
+
workService,
|
|
117
|
+
reviewService,
|
|
118
|
+
qaService,
|
|
119
|
+
selectionService,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
async close() {
|
|
123
|
+
const maybeClose = async (target) => {
|
|
124
|
+
try {
|
|
125
|
+
if (target?.close)
|
|
126
|
+
await target.close();
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
/* ignore */
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
await maybeClose(this.selectionService);
|
|
133
|
+
await maybeClose(this.deps.gatewayService);
|
|
134
|
+
await maybeClose(this.deps.routingService);
|
|
135
|
+
await maybeClose(this.deps.workService);
|
|
136
|
+
await maybeClose(this.deps.reviewService);
|
|
137
|
+
await maybeClose(this.deps.qaService);
|
|
138
|
+
await maybeClose(this.deps.jobService);
|
|
139
|
+
await maybeClose(this.deps.workspaceRepo);
|
|
140
|
+
}
|
|
141
|
+
disableDocdex(reason) {
|
|
142
|
+
this.deps.gatewayService.setDocdexAvailability(false, reason);
|
|
143
|
+
this.deps.workService.setDocdexAvailability(false, reason);
|
|
144
|
+
this.deps.reviewService.setDocdexAvailability(false, reason);
|
|
145
|
+
this.deps.qaService.setDocdexAvailability(false, reason);
|
|
146
|
+
}
|
|
147
|
+
async writeDocdexCheckArtifact(jobId, payload) {
|
|
148
|
+
const dir = path.join(this.trioDir(jobId), "docdex");
|
|
149
|
+
await PathHelper.ensureDir(dir);
|
|
150
|
+
const target = path.join(dir, "docdex-check.json");
|
|
151
|
+
await fs.writeFile(target, JSON.stringify(payload, null, 2), "utf8");
|
|
152
|
+
return path.relative(this.workspace.mcodaDir, target);
|
|
153
|
+
}
|
|
154
|
+
async runDocdexPreflight(jobId, warnings) {
|
|
155
|
+
const usingCustomCheck = Boolean(this.deps.docdexCheck);
|
|
156
|
+
if (!usingCustomCheck &&
|
|
157
|
+
(process.env.MCODA_SKIP_DOCDEX_CHECKS === "1" || process.env.MCODA_SKIP_DOCDEX_RUNTIME_CHECKS === "1")) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const configuredUrl = this.workspace.config?.docdexUrl ?? process.env.MCODA_DOCDEX_URL ?? process.env.DOCDEX_URL;
|
|
161
|
+
if (configuredUrl && !usingCustomCheck) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const checkFn = this.deps.docdexCheck ?? readDocdexCheck;
|
|
165
|
+
let summary;
|
|
166
|
+
let artifactPath;
|
|
167
|
+
try {
|
|
168
|
+
const check = await checkFn({ cwd: this.workspace.workspaceRoot });
|
|
169
|
+
summary = summarizeDocdexCheck(check);
|
|
170
|
+
artifactPath = await this.writeDocdexCheckArtifact(jobId, {
|
|
171
|
+
ok: summary.ok,
|
|
172
|
+
summary,
|
|
173
|
+
check,
|
|
174
|
+
timestamp: new Date().toISOString(),
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
179
|
+
summary = { ok: false, message };
|
|
180
|
+
artifactPath = await this.writeDocdexCheckArtifact(jobId, {
|
|
181
|
+
ok: false,
|
|
182
|
+
error: message,
|
|
183
|
+
timestamp: new Date().toISOString(),
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
await this.deps.jobService.writeCheckpoint(jobId, {
|
|
187
|
+
stage: "docdex:check",
|
|
188
|
+
timestamp: new Date().toISOString(),
|
|
189
|
+
details: {
|
|
190
|
+
ok: summary?.ok ?? false,
|
|
191
|
+
message: summary?.message,
|
|
192
|
+
artifactPath,
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
if (!summary?.ok) {
|
|
196
|
+
const hint = "Run `docdex check` to diagnose; ensure docdexd and ollama are running.";
|
|
197
|
+
const detail = summary?.message ? `Docdex unavailable: ${summary.message}.` : "Docdex unavailable.";
|
|
198
|
+
warnings.push(artifactPath ? `${detail} ${hint} (artifact: ${artifactPath})` : `${detail} ${hint}`);
|
|
199
|
+
this.disableDocdex(summary?.message ?? "docdex unavailable");
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
trioDir(jobId) {
|
|
203
|
+
return path.join(this.workspace.mcodaDir, "jobs", jobId, "gateway-trio");
|
|
204
|
+
}
|
|
205
|
+
statePath(jobId) {
|
|
206
|
+
return path.join(this.trioDir(jobId), "state.json");
|
|
207
|
+
}
|
|
208
|
+
async writeState(state) {
|
|
209
|
+
await PathHelper.ensureDir(this.trioDir(state.job_id));
|
|
210
|
+
await fs.writeFile(this.statePath(state.job_id), JSON.stringify(state, null, 2), "utf8");
|
|
211
|
+
}
|
|
212
|
+
async loadState(jobId) {
|
|
213
|
+
try {
|
|
214
|
+
const raw = await fs.readFile(this.statePath(jobId), "utf8");
|
|
215
|
+
return JSON.parse(raw);
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
return undefined;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
async readManifest(jobId) {
|
|
222
|
+
const manifestPath = path.join(this.workspace.mcodaDir, "jobs", jobId, "manifest.json");
|
|
223
|
+
try {
|
|
224
|
+
const raw = await fs.readFile(manifestPath, "utf8");
|
|
225
|
+
return JSON.parse(raw);
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
return undefined;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
isPseudoTaskKey(key) {
|
|
232
|
+
return key.trim().toUpperCase().startsWith(PSEUDO_TASK_PREFIX);
|
|
233
|
+
}
|
|
234
|
+
skipPseudoTask(state, taskKey, warnings) {
|
|
235
|
+
const progress = this.ensureProgress(state, taskKey);
|
|
236
|
+
progress.status = "skipped";
|
|
237
|
+
progress.lastError = "pseudo_task";
|
|
238
|
+
state.tasks[taskKey] = progress;
|
|
239
|
+
warnings.push(`Skipping pseudo task ${taskKey}.`);
|
|
240
|
+
}
|
|
241
|
+
async isTokenUsageCheckEnabled() {
|
|
242
|
+
if (this.tokenUsageCheckEnabled !== undefined)
|
|
243
|
+
return this.tokenUsageCheckEnabled;
|
|
244
|
+
const configPath = path.join(this.workspace.mcodaDir, "config.json");
|
|
245
|
+
try {
|
|
246
|
+
const raw = await fs.readFile(configPath, "utf8");
|
|
247
|
+
const parsed = JSON.parse(raw);
|
|
248
|
+
this.tokenUsageCheckEnabled = !parsed?.telemetry?.strict;
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
this.tokenUsageCheckEnabled = true;
|
|
252
|
+
}
|
|
253
|
+
return this.tokenUsageCheckEnabled;
|
|
254
|
+
}
|
|
255
|
+
async isZeroTokenRun(jobId, commandRunId) {
|
|
256
|
+
if (!jobId)
|
|
257
|
+
return undefined;
|
|
258
|
+
const enabled = await this.isTokenUsageCheckEnabled();
|
|
259
|
+
if (!enabled)
|
|
260
|
+
return undefined;
|
|
261
|
+
const tokenPath = path.join(this.workspace.mcodaDir, "token_usage.json");
|
|
262
|
+
let raw;
|
|
263
|
+
try {
|
|
264
|
+
raw = await fs.readFile(tokenPath, "utf8");
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
return undefined;
|
|
268
|
+
}
|
|
269
|
+
let entries;
|
|
270
|
+
try {
|
|
271
|
+
const parsed = JSON.parse(raw);
|
|
272
|
+
entries = Array.isArray(parsed) ? parsed : [];
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
return undefined;
|
|
276
|
+
}
|
|
277
|
+
if (!entries.length)
|
|
278
|
+
return undefined;
|
|
279
|
+
const relevant = entries.filter((entry) => {
|
|
280
|
+
if (!entry)
|
|
281
|
+
return false;
|
|
282
|
+
if (entry.jobId === jobId)
|
|
283
|
+
return true;
|
|
284
|
+
if (commandRunId && entry.commandRunId === commandRunId)
|
|
285
|
+
return true;
|
|
286
|
+
return false;
|
|
287
|
+
});
|
|
288
|
+
if (relevant.length === 0)
|
|
289
|
+
return undefined;
|
|
290
|
+
const total = relevant.reduce((sum, entry) => {
|
|
291
|
+
const prompt = Number(entry.tokensPrompt ?? entry.tokens_prompt ?? 0);
|
|
292
|
+
const completion = Number(entry.tokensCompletion ?? entry.tokens_completion ?? 0);
|
|
293
|
+
const rawTotal = entry.tokensTotal ?? entry.tokens_total;
|
|
294
|
+
const entryTotal = Number.isFinite(rawTotal) ? Number(rawTotal) : prompt + completion;
|
|
295
|
+
return sum + (Number.isFinite(entryTotal) ? entryTotal : 0);
|
|
296
|
+
}, 0);
|
|
297
|
+
return total <= 0;
|
|
298
|
+
}
|
|
299
|
+
async backoffZeroTokens(attempts) {
|
|
300
|
+
const backoffMs = ZERO_TOKEN_BACKOFF_MS * Math.max(1, Math.min(attempts, 2));
|
|
301
|
+
await new Promise((resolve) => setTimeout(resolve, backoffMs));
|
|
302
|
+
}
|
|
303
|
+
async cleanupExpiredTaskLocks(warnings) {
|
|
304
|
+
const cleared = await this.deps.workspaceRepo.cleanupExpiredTaskLocks();
|
|
305
|
+
if (cleared.length > 0) {
|
|
306
|
+
warnings.push(`Cleared ${cleared.length} expired task lock(s): ${cleared.join(", ")}`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
assertResumeAllowed(job, manifest) {
|
|
310
|
+
const state = job.jobState ?? job.state ?? job.status ?? "unknown";
|
|
311
|
+
if (["completed", "cancelled"].includes(state)) {
|
|
312
|
+
throw new Error(`Job ${job.id} is ${state}; cannot resume.`);
|
|
313
|
+
}
|
|
314
|
+
if (["running", "queued", "checkpointing"].includes(state)) {
|
|
315
|
+
throw new Error(`Job ${job.id} is ${state}; wait for it to finish or cancel before resuming.`);
|
|
316
|
+
}
|
|
317
|
+
const supported = job.resumeSupported ?? job.resume_supported ?? job.payload?.resumeSupported ?? job.payload?.resume_supported;
|
|
318
|
+
if (supported === 0 || supported === false) {
|
|
319
|
+
throw new Error(`Job ${job.id} does not support resume.`);
|
|
320
|
+
}
|
|
321
|
+
if (!manifest) {
|
|
322
|
+
throw new Error(`Missing manifest for job ${job.id}; cannot resume safely.`);
|
|
323
|
+
}
|
|
324
|
+
const manifestJobId = manifest.job_id ?? manifest.id;
|
|
325
|
+
if (manifestJobId && manifestJobId !== job.id) {
|
|
326
|
+
throw new Error(`Checkpoint manifest for ${job.id} does not match job id (${manifestJobId}); aborting resume.`);
|
|
327
|
+
}
|
|
328
|
+
const manifestType = manifest.type ?? manifest.job_type;
|
|
329
|
+
if (manifestType && manifestType !== job.type) {
|
|
330
|
+
throw new Error(`Checkpoint manifest type (${manifestType}) does not match job type (${job.type}); cannot resume.`);
|
|
331
|
+
}
|
|
332
|
+
const manifestCommand = manifest.command ?? manifest.command_name ?? manifest.commandName;
|
|
333
|
+
if (manifestCommand && job.commandName && manifestCommand !== job.commandName) {
|
|
334
|
+
throw new Error(`Checkpoint manifest command (${manifestCommand}) does not match job command (${job.commandName}); cannot resume.`);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
async writeHandoffArtifact(jobId, taskKey, step, attempt, content) {
|
|
338
|
+
const dir = path.join(this.trioDir(jobId), "handoffs");
|
|
339
|
+
await PathHelper.ensureDir(dir);
|
|
340
|
+
const safeKey = taskKey.replace(/[^a-z0-9_-]+/gi, "_");
|
|
341
|
+
const filename = `${String(attempt).padStart(2, "0")}-${safeKey}-${step}.md`;
|
|
342
|
+
const target = path.join(dir, filename);
|
|
343
|
+
await fs.writeFile(target, content, "utf8");
|
|
344
|
+
return target;
|
|
345
|
+
}
|
|
346
|
+
async prepareHandoff(jobId, taskKey, step, attempt, content) {
|
|
347
|
+
const safeKey = taskKey.replace(/[^a-z0-9_-]+/gi, "_");
|
|
348
|
+
const handoffId = `${safeKey}-${step}-${String(attempt).padStart(2, "0")}`;
|
|
349
|
+
const handoffPath = await writeGatewayHandoffFile(this.workspace.workspaceRoot, handoffId, content, "gateway-trio");
|
|
350
|
+
await this.writeHandoffArtifact(jobId, taskKey, step, attempt, content);
|
|
351
|
+
return handoffPath;
|
|
352
|
+
}
|
|
353
|
+
async projectKeyForTask(projectId) {
|
|
354
|
+
if (!projectId)
|
|
355
|
+
return undefined;
|
|
356
|
+
if (this.projectKeyCache.has(projectId))
|
|
357
|
+
return this.projectKeyCache.get(projectId);
|
|
358
|
+
const project = await this.deps.workspaceRepo.getProjectById(projectId);
|
|
359
|
+
if (!project)
|
|
360
|
+
return undefined;
|
|
361
|
+
this.projectKeyCache.set(projectId, project.key);
|
|
362
|
+
return project.key;
|
|
363
|
+
}
|
|
364
|
+
async seedExplicitTasks(state, explicitTasks, warnings) {
|
|
365
|
+
for (const taskKey of explicitTasks) {
|
|
366
|
+
if (state.tasks[taskKey])
|
|
367
|
+
continue;
|
|
368
|
+
const task = await this.deps.workspaceRepo.getTaskByKey(taskKey);
|
|
369
|
+
if (!task) {
|
|
370
|
+
warnings.push(`Explicit task ${taskKey} not found; skipping.`);
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
this.ensureProgress(state, taskKey);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
ensureProgress(state, taskKey) {
|
|
377
|
+
const existing = state.tasks[taskKey];
|
|
378
|
+
if (existing) {
|
|
379
|
+
if (!existing.chosenAgents)
|
|
380
|
+
existing.chosenAgents = {};
|
|
381
|
+
if (!existing.failureHistory)
|
|
382
|
+
existing.failureHistory = [];
|
|
383
|
+
if (!existing.escalationAttempts)
|
|
384
|
+
existing.escalationAttempts = {};
|
|
385
|
+
if (!existing.stepOutcomes)
|
|
386
|
+
existing.stepOutcomes = {};
|
|
387
|
+
if (!existing.decisionHistory)
|
|
388
|
+
existing.decisionHistory = [];
|
|
389
|
+
return existing;
|
|
390
|
+
}
|
|
391
|
+
const created = {
|
|
392
|
+
taskKey,
|
|
393
|
+
attempts: 0,
|
|
394
|
+
status: "pending",
|
|
395
|
+
chosenAgents: {},
|
|
396
|
+
failureHistory: [],
|
|
397
|
+
escalationAttempts: {},
|
|
398
|
+
stepOutcomes: {},
|
|
399
|
+
decisionHistory: [],
|
|
400
|
+
};
|
|
401
|
+
state.tasks[taskKey] = created;
|
|
402
|
+
return created;
|
|
403
|
+
}
|
|
404
|
+
dedupeTaskKeys(keys) {
|
|
405
|
+
const seen = new Set();
|
|
406
|
+
const result = [];
|
|
407
|
+
for (const key of keys) {
|
|
408
|
+
const trimmed = key.trim();
|
|
409
|
+
if (!trimmed || seen.has(trimmed))
|
|
410
|
+
continue;
|
|
411
|
+
seen.add(trimmed);
|
|
412
|
+
result.push(trimmed);
|
|
413
|
+
}
|
|
414
|
+
return result;
|
|
415
|
+
}
|
|
416
|
+
buildRunListFromExplicit(taskKeys, limit, warnings) {
|
|
417
|
+
const filtered = taskKeys.filter((key) => !this.isPseudoTaskKey(key));
|
|
418
|
+
let deduped = this.dedupeTaskKeys(filtered);
|
|
419
|
+
if (typeof limit === "number" && limit > 0 && deduped.length > limit) {
|
|
420
|
+
warnings.push(`Run list limited to ${limit} explicit tasks; skipping ${deduped.length - limit} task(s).`);
|
|
421
|
+
deduped = deduped.slice(0, limit);
|
|
422
|
+
}
|
|
423
|
+
return deduped;
|
|
424
|
+
}
|
|
425
|
+
async buildRunListFromSelection(filters, limit, warnings) {
|
|
426
|
+
const selection = await this.selectionService.selectTasks(filters);
|
|
427
|
+
if (selection.warnings.length)
|
|
428
|
+
warnings.push(...selection.warnings);
|
|
429
|
+
const orderedKeys = selection.ordered.map((entry) => entry.task.key).filter((key) => !this.isPseudoTaskKey(key));
|
|
430
|
+
let deduped = this.dedupeTaskKeys(orderedKeys);
|
|
431
|
+
if (typeof limit === "number" && limit > 0 && deduped.length > limit) {
|
|
432
|
+
warnings.push(`Run list limited to ${limit} tasks; skipping ${deduped.length - limit} task(s).`);
|
|
433
|
+
deduped = deduped.slice(0, limit);
|
|
434
|
+
}
|
|
435
|
+
return deduped;
|
|
436
|
+
}
|
|
437
|
+
async guardMissingContext(step, jobId, taskKey, gateway, warnings, resolvedAgent) {
|
|
438
|
+
const filesMissing = gateway.analysis.filesLikelyTouched.length === 0 && gateway.analysis.filesToCreate.length === 0;
|
|
439
|
+
const docdexMissing = gateway.docdex.length === 0;
|
|
440
|
+
const docdexNotesText = gateway.analysis.docdexNotes.join(" ").toLowerCase();
|
|
441
|
+
const docdexSkipped = DOCDEX_SKIP_PATTERN.test(docdexNotesText);
|
|
442
|
+
const docdexExplicitMissing = DOCDEX_MISSING_PATTERN.test(docdexNotesText);
|
|
443
|
+
const missingTaskSignal = this.hasMissingTaskSignal(gateway.analysis);
|
|
444
|
+
if (!missingTaskSignal && (!filesMissing || !docdexMissing) && !(docdexSkipped && !docdexExplicitMissing)) {
|
|
445
|
+
return undefined;
|
|
446
|
+
}
|
|
447
|
+
const messageLines = [
|
|
448
|
+
missingTaskSignal
|
|
449
|
+
? "Gateway analysis indicates the task details are missing; proceeding with execution anyway."
|
|
450
|
+
: docdexSkipped && !docdexExplicitMissing
|
|
451
|
+
? "Gateway analysis reported docdex work as not executed; proceeding without docdex context."
|
|
452
|
+
: "Gateway analysis returned no file paths and no docdex context; proceeding.",
|
|
453
|
+
gateway.analysis.docdexNotes.length ? `Docdex notes: ${gateway.analysis.docdexNotes.join(" | ")}` : "Docdex notes: (none)",
|
|
454
|
+
gateway.analysis.assumptions.length ? `Assumptions: ${gateway.analysis.assumptions.join(" | ")}` : undefined,
|
|
455
|
+
].filter(Boolean);
|
|
456
|
+
warnings.push(`Task ${taskKey} (${step}) gateway context incomplete.\n${messageLines.join("\n")}`);
|
|
457
|
+
return undefined;
|
|
458
|
+
}
|
|
459
|
+
hasMissingTaskSignal(analysis) {
|
|
460
|
+
const fields = [
|
|
461
|
+
analysis.summary,
|
|
462
|
+
analysis.currentState,
|
|
463
|
+
analysis.todo,
|
|
464
|
+
analysis.understanding,
|
|
465
|
+
...(analysis.assumptions ?? []),
|
|
466
|
+
...(analysis.docdexNotes ?? []),
|
|
467
|
+
]
|
|
468
|
+
.filter(Boolean)
|
|
469
|
+
.join(" ");
|
|
470
|
+
return MISSING_TASK_PATTERN.test(fields);
|
|
471
|
+
}
|
|
472
|
+
hasReachedMaxIterations(progress, maxIterations) {
|
|
473
|
+
if (maxIterations === undefined)
|
|
474
|
+
return false;
|
|
475
|
+
const attempts = progress?.attempts ?? 0;
|
|
476
|
+
return attempts >= maxIterations;
|
|
477
|
+
}
|
|
478
|
+
hasIterationsRemaining(progress, maxIterations) {
|
|
479
|
+
if (maxIterations === undefined)
|
|
480
|
+
return true;
|
|
481
|
+
return progress.attempts < maxIterations;
|
|
482
|
+
}
|
|
483
|
+
shouldReopenFailedTask(progress, taskKey, warnings, continuousMode) {
|
|
484
|
+
if (!progress)
|
|
485
|
+
return true;
|
|
486
|
+
if (continuousMode)
|
|
487
|
+
return true;
|
|
488
|
+
if (progress.lastGuardrailRetryable === false) {
|
|
489
|
+
const lastFailure = progress.failureHistory?.[progress.failureHistory.length - 1];
|
|
490
|
+
const lastReasonRaw = progress.lastError ?? lastFailure?.reason ?? "guardrail_non_retryable";
|
|
491
|
+
warnings.push(`Task ${taskKey} failed with non-retryable reason ${lastReasonRaw}; skipping reopen.`);
|
|
492
|
+
return false;
|
|
493
|
+
}
|
|
494
|
+
const lastFailure = progress.failureHistory?.[progress.failureHistory.length - 1];
|
|
495
|
+
const lastReasonRaw = progress.lastError ?? lastFailure?.reason ?? "";
|
|
496
|
+
const lastReason = normalizeFailureReason(lastReasonRaw);
|
|
497
|
+
if (lastReason) {
|
|
498
|
+
if (NON_RETRYABLE_FAILURE_REASONS.has(lastReason)) {
|
|
499
|
+
warnings.push(`Task ${taskKey} failed with non-retryable reason ${lastReason}; skipping reopen.`);
|
|
500
|
+
return false;
|
|
501
|
+
}
|
|
502
|
+
if (/no eligible agents|missing required capabilities|agent .* missing required capabilities/i.test(lastReasonRaw)) {
|
|
503
|
+
warnings.push(`Task ${taskKey} failed due to agent selection; skipping reopen.`);
|
|
504
|
+
return false;
|
|
505
|
+
}
|
|
506
|
+
const sameReasonCount = progress.failureHistory?.filter((failure) => failure.reason === lastReason).length ?? 0;
|
|
507
|
+
if (sameReasonCount >= MAX_FAILURE_REOPENS_PER_REASON) {
|
|
508
|
+
warnings.push(`Task ${taskKey} hit retry cap for ${lastReason}; skipping reopen.`);
|
|
509
|
+
return false;
|
|
510
|
+
}
|
|
511
|
+
const lastTimestamp = lastFailure?.timestamp ? Date.parse(lastFailure.timestamp) : undefined;
|
|
512
|
+
if (Number.isFinite(lastTimestamp) && Date.now() - lastTimestamp < FAILED_REOPEN_COOLDOWN_MS) {
|
|
513
|
+
warnings.push(`Task ${taskKey} failed recently (${lastReason}); cooling down before reopen.`);
|
|
514
|
+
return false;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
return true;
|
|
518
|
+
}
|
|
519
|
+
async reopenRetryableFailedTasks(state, explicitTasks, maxIterations, warnings) {
|
|
520
|
+
const continuousMode = maxIterations === undefined;
|
|
521
|
+
const keys = new Set([...explicitTasks, ...Object.keys(state.tasks)]);
|
|
522
|
+
for (const taskKey of keys) {
|
|
523
|
+
const progress = state.tasks[taskKey];
|
|
524
|
+
if (progress?.status === "completed")
|
|
525
|
+
continue;
|
|
526
|
+
const task = await this.deps.workspaceRepo.getTaskByKey(taskKey);
|
|
527
|
+
if (!task)
|
|
528
|
+
continue;
|
|
529
|
+
const status = this.normalizeStatus(task.status);
|
|
530
|
+
if (status === "completed" || status === "cancelled") {
|
|
531
|
+
const terminalProgress = progress ?? this.ensureProgress(state, taskKey);
|
|
532
|
+
terminalProgress.status = status === "completed" ? "completed" : "skipped";
|
|
533
|
+
terminalProgress.lastError = status === "completed" ? "completed_in_db" : "cancelled_in_db";
|
|
534
|
+
state.tasks[taskKey] = terminalProgress;
|
|
535
|
+
warnings.push(`Task ${taskKey} is ${status}; skipping reopen.`);
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
if (this.hasReachedMaxIterations(progress, maxIterations)) {
|
|
539
|
+
if (progress) {
|
|
540
|
+
progress.status = "failed";
|
|
541
|
+
if (!progress.lastError) {
|
|
542
|
+
progress.lastError = "max_iterations_reached";
|
|
543
|
+
}
|
|
544
|
+
state.tasks[taskKey] = progress;
|
|
545
|
+
}
|
|
546
|
+
if (maxIterations !== undefined) {
|
|
547
|
+
warnings.push(`Task ${taskKey} hit max iterations (${maxIterations}); skipping reopen.`);
|
|
548
|
+
}
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
const metadata = task.metadata ?? {};
|
|
552
|
+
const failedReason = typeof metadata.failed_reason === "string" ? metadata.failed_reason : undefined;
|
|
553
|
+
if (status === "failed") {
|
|
554
|
+
if (failedReason === "dependency_not_ready") {
|
|
555
|
+
const depsReady = await this.dependenciesReady(task.id, warnings);
|
|
556
|
+
if (!depsReady)
|
|
557
|
+
continue;
|
|
558
|
+
}
|
|
559
|
+
if (!continuousMode &&
|
|
560
|
+
(progress?.lastGuardrailRetryable === false ||
|
|
561
|
+
(failedReason && NON_RETRYABLE_FAILURE_REASONS.has(failedReason)))) {
|
|
562
|
+
const reasonLabel = failedReason ?? progress?.lastError ?? progress?.lastEscalationReason ?? "guardrail_non_retryable";
|
|
563
|
+
warnings.push(`Task ${taskKey} failed with non-retryable reason ${reasonLabel}; skipping reopen.`);
|
|
564
|
+
continue;
|
|
565
|
+
}
|
|
566
|
+
if (!continuousMode && progress && !this.shouldReopenFailedTask(progress, taskKey, warnings, continuousMode)) {
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
else if (progress?.status !== "failed") {
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
573
|
+
else if (!continuousMode && !this.shouldReopenFailedTask(progress, taskKey, warnings, continuousMode)) {
|
|
574
|
+
continue;
|
|
575
|
+
}
|
|
576
|
+
const nextMetadata = { ...metadata };
|
|
577
|
+
delete nextMetadata.failed_reason;
|
|
578
|
+
if (status === "failed") {
|
|
579
|
+
await this.deps.workspaceRepo.updateTask(task.id, {
|
|
580
|
+
status: "in_progress",
|
|
581
|
+
metadata: nextMetadata,
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
if (progress) {
|
|
585
|
+
progress.status = "pending";
|
|
586
|
+
progress.lastError = undefined;
|
|
587
|
+
state.tasks[taskKey] = progress;
|
|
588
|
+
}
|
|
589
|
+
warnings.push(`Reopened failed task ${taskKey} (reason=${failedReason ?? progress?.lastError ?? "unknown"}) for retry (attempts=${progress?.attempts ?? 0}${maxIterations !== undefined ? `/${maxIterations}` : ""}).`);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
async dependenciesReady(taskId, warnings) {
|
|
593
|
+
const deps = await this.deps.workspaceRepo.getTaskDependencies([taskId]);
|
|
594
|
+
if (!deps.length)
|
|
595
|
+
return true;
|
|
596
|
+
const depIds = deps.map((dep) => dep.dependsOnTaskId).filter((id) => Boolean(id));
|
|
597
|
+
if (!depIds.length)
|
|
598
|
+
return true;
|
|
599
|
+
const depTasks = await this.deps.workspaceRepo.getTasksByIds(depIds);
|
|
600
|
+
const depMap = new Map(depTasks.map((task) => [task.id, task]));
|
|
601
|
+
for (const depId of depIds) {
|
|
602
|
+
const depTask = depMap.get(depId);
|
|
603
|
+
if (!depTask) {
|
|
604
|
+
warnings.push(`Dependency ${depId} not found for task ${taskId}; treating as not ready.`);
|
|
605
|
+
return false;
|
|
606
|
+
}
|
|
607
|
+
const status = this.normalizeStatus(depTask.status);
|
|
608
|
+
if (!status || !DONE_DEPENDENCY_STATUSES.has(status))
|
|
609
|
+
return false;
|
|
610
|
+
}
|
|
611
|
+
return true;
|
|
612
|
+
}
|
|
613
|
+
normalizeStatus(status) {
|
|
614
|
+
return status ? status.toLowerCase().trim() : undefined;
|
|
615
|
+
}
|
|
616
|
+
resolveRequest(request, payload) {
|
|
617
|
+
if (!payload) {
|
|
618
|
+
return { ...request, rateAgents: request.rateAgents ?? true };
|
|
619
|
+
}
|
|
620
|
+
const raw = payload;
|
|
621
|
+
const payloadTasks = Array.isArray(raw.tasks) ? raw.tasks : undefined;
|
|
622
|
+
const payloadStatuses = Array.isArray(raw.statusFilter) ? raw.statusFilter : undefined;
|
|
623
|
+
const resolvedQaResult = (request.qaResult ?? raw.qaResult);
|
|
624
|
+
const normalizedQaResult = resolvedQaResult === "blocked" ? "fail" : resolvedQaResult;
|
|
625
|
+
return {
|
|
626
|
+
...request,
|
|
627
|
+
projectKey: request.projectKey ?? raw.projectKey,
|
|
628
|
+
epicKey: request.epicKey ?? raw.epicKey,
|
|
629
|
+
storyKey: request.storyKey ?? raw.storyKey,
|
|
630
|
+
taskKeys: request.taskKeys && request.taskKeys.length ? request.taskKeys : payloadTasks,
|
|
631
|
+
statusFilter: request.statusFilter && request.statusFilter.length ? request.statusFilter : payloadStatuses,
|
|
632
|
+
limit: request.limit ?? raw.limit,
|
|
633
|
+
parallel: request.parallel ?? raw.parallel,
|
|
634
|
+
maxIterations: request.maxIterations ?? raw.maxIterations,
|
|
635
|
+
maxCycles: request.maxCycles ?? raw.maxCycles,
|
|
636
|
+
gatewayAgentName: request.gatewayAgentName ?? raw.gatewayAgentName,
|
|
637
|
+
workAgentName: request.workAgentName ?? raw.workAgentName,
|
|
638
|
+
reviewAgentName: request.reviewAgentName ?? raw.reviewAgentName,
|
|
639
|
+
qaAgentName: request.qaAgentName ?? raw.qaAgentName,
|
|
640
|
+
maxDocs: request.maxDocs ?? raw.maxDocs,
|
|
641
|
+
agentStream: request.agentStream ?? raw.agentStream,
|
|
642
|
+
rateAgents: request.rateAgents ?? raw.rateAgents ?? true,
|
|
643
|
+
noCommit: request.noCommit ?? raw.noCommit,
|
|
644
|
+
dryRun: request.dryRun ?? raw.dryRun,
|
|
645
|
+
reviewBase: request.reviewBase ?? raw.reviewBase,
|
|
646
|
+
maxAgentSeconds: request.maxAgentSeconds ?? raw.maxAgentSeconds,
|
|
647
|
+
qaProfileName: request.qaProfileName ?? raw.qaProfileName,
|
|
648
|
+
qaLevel: request.qaLevel ?? raw.qaLevel,
|
|
649
|
+
qaTestCommand: request.qaTestCommand ?? raw.qaTestCommand,
|
|
650
|
+
qaMode: request.qaMode ?? raw.qaMode,
|
|
651
|
+
qaFollowups: request.qaFollowups ?? raw.qaFollowups,
|
|
652
|
+
reviewFollowups: request.reviewFollowups ?? raw.reviewFollowups,
|
|
653
|
+
qaResult: normalizedQaResult,
|
|
654
|
+
qaNotes: request.qaNotes ?? raw.qaNotes,
|
|
655
|
+
qaEvidenceUrl: request.qaEvidenceUrl ?? raw.qaEvidenceUrl,
|
|
656
|
+
qaAllowDirty: request.qaAllowDirty ?? raw.qaAllowDirty,
|
|
657
|
+
escalateOnNoChange: request.escalateOnNoChange ?? raw.escalateOnNoChange,
|
|
658
|
+
workRunner: request.workRunner ?? raw.workRunner,
|
|
659
|
+
useCodali: request.useCodali ?? raw.useCodali,
|
|
660
|
+
agentAdapterOverride: request.agentAdapterOverride ?? raw.agentAdapterOverride,
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
async buildStatusFilter(request, warnings) {
|
|
664
|
+
const base = request.statusFilter && request.statusFilter.length ? request.statusFilter : DEFAULT_STATUS_FILTER;
|
|
665
|
+
const normalized = new Set(base.map((s) => this.normalizeStatus(s)).filter(Boolean));
|
|
666
|
+
const explicit = request.taskKeys ?? [];
|
|
667
|
+
for (const key of explicit) {
|
|
668
|
+
const task = await this.deps.workspaceRepo.getTaskByKey(key);
|
|
669
|
+
if (!task) {
|
|
670
|
+
warnings.push(`Task not found: ${key}`);
|
|
671
|
+
continue;
|
|
672
|
+
}
|
|
673
|
+
const status = this.normalizeStatus(task.status);
|
|
674
|
+
if (!status)
|
|
675
|
+
continue;
|
|
676
|
+
if (TERMINAL_STATUSES.has(status)) {
|
|
677
|
+
warnings.push(`Skipping terminal task ${key} (${status}).`);
|
|
678
|
+
continue;
|
|
679
|
+
}
|
|
680
|
+
if (!normalized.has(status))
|
|
681
|
+
normalized.add(status);
|
|
682
|
+
}
|
|
683
|
+
return Array.from(normalized);
|
|
684
|
+
}
|
|
685
|
+
async refreshTaskStatus(taskKey, warnings) {
|
|
686
|
+
const task = await this.deps.workspaceRepo.getTaskByKey(taskKey);
|
|
687
|
+
if (!task) {
|
|
688
|
+
warnings.push(`Task ${taskKey} not found while refreshing status.`);
|
|
689
|
+
return undefined;
|
|
690
|
+
}
|
|
691
|
+
return this.normalizeStatus(task.status);
|
|
692
|
+
}
|
|
693
|
+
parseWorkResult(taskKey, result) {
|
|
694
|
+
const entry = result.results.find((r) => r.taskKey === taskKey);
|
|
695
|
+
if (!entry) {
|
|
696
|
+
return { step: "work", status: "failed", error: "Task not processed by work-on-tasks" };
|
|
697
|
+
}
|
|
698
|
+
const guardrail = parseGuardrailReason(entry.notes);
|
|
699
|
+
if (entry.status === "succeeded") {
|
|
700
|
+
return { step: "work", status: "succeeded" };
|
|
701
|
+
}
|
|
702
|
+
if (entry.status === "skipped") {
|
|
703
|
+
return {
|
|
704
|
+
step: "work",
|
|
705
|
+
status: "skipped",
|
|
706
|
+
error: guardrail?.reason ?? entry.notes,
|
|
707
|
+
guardrailReason: guardrail?.reason,
|
|
708
|
+
guardrailRetryable: guardrail?.retryable,
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
return {
|
|
712
|
+
step: "work",
|
|
713
|
+
status: "failed",
|
|
714
|
+
error: guardrail?.reason ?? entry.notes,
|
|
715
|
+
guardrailReason: guardrail?.reason,
|
|
716
|
+
guardrailRetryable: guardrail?.retryable,
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
parseReviewResult(taskKey, result) {
|
|
720
|
+
const entry = result.tasks.find((t) => t.taskKey === taskKey);
|
|
721
|
+
if (!entry) {
|
|
722
|
+
return { step: "review", status: "failed", error: "Task not processed by code-review" };
|
|
723
|
+
}
|
|
724
|
+
if (entry.error) {
|
|
725
|
+
const guardrail = parseGuardrailReason(entry.error);
|
|
726
|
+
return {
|
|
727
|
+
step: "review",
|
|
728
|
+
status: "failed",
|
|
729
|
+
error: guardrail?.reason ?? entry.error,
|
|
730
|
+
guardrailReason: guardrail?.reason,
|
|
731
|
+
guardrailRetryable: guardrail?.retryable,
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
const decision = entry.decision ?? "error";
|
|
735
|
+
if (decision === "approve" || decision === "info_only")
|
|
736
|
+
return { step: "review", status: "succeeded", decision };
|
|
737
|
+
if (decision === "changes_requested")
|
|
738
|
+
return { step: "review", status: "succeeded", decision };
|
|
739
|
+
if (decision === "block")
|
|
740
|
+
return { step: "review", status: "failed", decision };
|
|
741
|
+
return { step: "review", status: "failed", decision };
|
|
742
|
+
}
|
|
743
|
+
parseQaResult(taskKey, result) {
|
|
744
|
+
const entry = result.results.find((r) => r.taskKey === taskKey);
|
|
745
|
+
if (!entry) {
|
|
746
|
+
return { step: "qa", status: "failed", error: "Task not processed by qa-tasks" };
|
|
747
|
+
}
|
|
748
|
+
const guardrail = parseGuardrailReason(entry.outcome);
|
|
749
|
+
if (entry.outcome === "pass") {
|
|
750
|
+
return {
|
|
751
|
+
step: "qa",
|
|
752
|
+
status: "succeeded",
|
|
753
|
+
outcome: entry.outcome,
|
|
754
|
+
notes: entry.notes,
|
|
755
|
+
artifacts: entry.artifacts,
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
if (entry.outcome === "infra_issue") {
|
|
759
|
+
return {
|
|
760
|
+
step: "qa",
|
|
761
|
+
status: "failed",
|
|
762
|
+
outcome: entry.outcome,
|
|
763
|
+
notes: entry.notes,
|
|
764
|
+
artifacts: entry.artifacts,
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
if (entry.outcome === "fix_required" || entry.outcome === "unclear") {
|
|
768
|
+
return {
|
|
769
|
+
step: "qa",
|
|
770
|
+
status: "failed",
|
|
771
|
+
outcome: entry.outcome,
|
|
772
|
+
notes: entry.notes,
|
|
773
|
+
artifacts: entry.artifacts,
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
return {
|
|
777
|
+
step: "qa",
|
|
778
|
+
status: "failed",
|
|
779
|
+
outcome: guardrail?.reason ?? entry.outcome,
|
|
780
|
+
notes: entry.notes,
|
|
781
|
+
artifacts: entry.artifacts,
|
|
782
|
+
guardrailReason: guardrail?.reason,
|
|
783
|
+
guardrailRetryable: guardrail?.retryable,
|
|
784
|
+
};
|
|
785
|
+
}
|
|
786
|
+
isAuthFailure(reason) {
|
|
787
|
+
return normalizeFailureReason(reason) === AUTH_ERROR_REASON;
|
|
788
|
+
}
|
|
789
|
+
shouldRetryAfter(step) {
|
|
790
|
+
if (step.status === "skipped")
|
|
791
|
+
return false;
|
|
792
|
+
if (typeof step.guardrailRetryable === "boolean")
|
|
793
|
+
return step.guardrailRetryable;
|
|
794
|
+
const reason = normalizeFailureReason(step.error ?? step.decision ?? step.outcome ?? "");
|
|
795
|
+
if (reason === "infra_issue" || reason === "block")
|
|
796
|
+
return false;
|
|
797
|
+
if (reason && NON_RETRYABLE_FAILURE_REASONS.has(reason))
|
|
798
|
+
return false;
|
|
799
|
+
return step.status !== "succeeded";
|
|
800
|
+
}
|
|
801
|
+
escalationReasons(escalateOnNoChange) {
|
|
802
|
+
const reasons = new Set(ESCALATION_REASONS);
|
|
803
|
+
if (escalateOnNoChange)
|
|
804
|
+
reasons.add(NO_CHANGE_REASON);
|
|
805
|
+
return reasons;
|
|
806
|
+
}
|
|
807
|
+
resolveTierFromComplexity(complexity) {
|
|
808
|
+
if (typeof complexity !== "number" || !Number.isFinite(complexity))
|
|
809
|
+
return "unknown";
|
|
810
|
+
const normalized = Math.round(complexity);
|
|
811
|
+
if (normalized >= SPECIALIST_TIER_MIN_COMPLEXITY)
|
|
812
|
+
return "specialist";
|
|
813
|
+
if (normalized >= STRONG_TIER_MIN_COMPLEXITY)
|
|
814
|
+
return "strong";
|
|
815
|
+
return "cheap";
|
|
816
|
+
}
|
|
817
|
+
nextTier(current) {
|
|
818
|
+
if (current === "strong")
|
|
819
|
+
return "specialist";
|
|
820
|
+
if (current === "specialist")
|
|
821
|
+
return undefined;
|
|
822
|
+
return "strong";
|
|
823
|
+
}
|
|
824
|
+
recordFailure(progress, step, attempt) {
|
|
825
|
+
if (step.status !== "failed")
|
|
826
|
+
return;
|
|
827
|
+
const reason = step.guardrailReason ?? step.error ?? step.decision ?? step.outcome;
|
|
828
|
+
const agent = step.chosenAgent;
|
|
829
|
+
if (typeof step.guardrailRetryable === "boolean") {
|
|
830
|
+
progress.lastGuardrailRetryable = step.guardrailRetryable;
|
|
831
|
+
}
|
|
832
|
+
if (!reason)
|
|
833
|
+
return;
|
|
834
|
+
const normalized = normalizeFailureReason(reason) ?? reason;
|
|
835
|
+
progress.lastEscalationReason = normalized;
|
|
836
|
+
const attempts = progress.escalationAttempts ?? {};
|
|
837
|
+
attempts[normalized] = (attempts[normalized] ?? 0) + 1;
|
|
838
|
+
progress.escalationAttempts = attempts;
|
|
839
|
+
if (!agent)
|
|
840
|
+
return;
|
|
841
|
+
const history = progress.failureHistory ?? [];
|
|
842
|
+
history.push({ step: step.step, agent, reason, attempt, timestamp: new Date().toISOString() });
|
|
843
|
+
progress.failureHistory = history;
|
|
844
|
+
}
|
|
845
|
+
mergeGatewayFiles(outcome) {
|
|
846
|
+
const values = [...(outcome.gatewayFiles ?? [])];
|
|
847
|
+
const unique = new Set();
|
|
848
|
+
const merged = [];
|
|
849
|
+
for (const value of values) {
|
|
850
|
+
const trimmed = value.trim();
|
|
851
|
+
if (!trimmed || unique.has(trimmed))
|
|
852
|
+
continue;
|
|
853
|
+
unique.add(trimmed);
|
|
854
|
+
merged.push(trimmed);
|
|
855
|
+
}
|
|
856
|
+
return merged;
|
|
857
|
+
}
|
|
858
|
+
handoffContextForProgress(progress) {
|
|
859
|
+
const qaFailureSummary = progress.lastQaFailureSummary?.trim();
|
|
860
|
+
const learningSummary = progress.pendingLearningSummary?.trim();
|
|
861
|
+
if (!qaFailureSummary && !learningSummary)
|
|
862
|
+
return undefined;
|
|
863
|
+
return {
|
|
864
|
+
qaFailureSummary,
|
|
865
|
+
learningSummary,
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
appendFallbackHandoffContext(baseLines, context) {
|
|
869
|
+
if (!context?.qaFailureSummary?.trim() && !context?.learningSummary?.trim()) {
|
|
870
|
+
return baseLines;
|
|
871
|
+
}
|
|
872
|
+
const lines = [...baseLines];
|
|
873
|
+
if (context.qaFailureSummary?.trim()) {
|
|
874
|
+
lines.push("", "## QA Failure Summary", context.qaFailureSummary.trim());
|
|
875
|
+
}
|
|
876
|
+
if (context.learningSummary?.trim()) {
|
|
877
|
+
lines.push("", "## Revert Learning", context.learningSummary.trim());
|
|
878
|
+
}
|
|
879
|
+
return lines;
|
|
880
|
+
}
|
|
881
|
+
recordDecision(progress, step, attempt, kind, status, detail) {
|
|
882
|
+
const stepOutcomes = progress.stepOutcomes ?? {};
|
|
883
|
+
stepOutcomes[step] = detail ? `${status}:${detail}` : status;
|
|
884
|
+
progress.stepOutcomes = stepOutcomes;
|
|
885
|
+
const history = progress.decisionHistory ?? [];
|
|
886
|
+
history.push({
|
|
887
|
+
step,
|
|
888
|
+
kind,
|
|
889
|
+
status,
|
|
890
|
+
detail,
|
|
891
|
+
attempt,
|
|
892
|
+
timestamp: new Date().toISOString(),
|
|
893
|
+
});
|
|
894
|
+
progress.decisionHistory = history.slice(Math.max(0, history.length - DECISION_HISTORY_MAX));
|
|
895
|
+
}
|
|
896
|
+
buildQaFailureSummary(taskKey, outcome) {
|
|
897
|
+
const fragments = [];
|
|
898
|
+
fragments.push(`Task ${taskKey} QA outcome: ${outcome.outcome ?? outcome.status}.`);
|
|
899
|
+
if (outcome.error) {
|
|
900
|
+
fragments.push(`Error: ${outcome.error}.`);
|
|
901
|
+
}
|
|
902
|
+
if (outcome.notes) {
|
|
903
|
+
fragments.push(`Notes: ${outcome.notes}.`);
|
|
904
|
+
}
|
|
905
|
+
if (outcome.artifacts?.length) {
|
|
906
|
+
fragments.push(`Artifacts: ${outcome.artifacts.join(", ")}.`);
|
|
907
|
+
}
|
|
908
|
+
return fragments.join(" ").trim();
|
|
909
|
+
}
|
|
910
|
+
extractRevertLearning(taskKey, metadata) {
|
|
911
|
+
if (!metadata || typeof metadata !== "object")
|
|
912
|
+
return undefined;
|
|
913
|
+
const pending = metadata.revert_event_pending === true;
|
|
914
|
+
const rawEvent = metadata.last_revert_event;
|
|
915
|
+
if (!pending || !rawEvent || typeof rawEvent !== "object")
|
|
916
|
+
return undefined;
|
|
917
|
+
const reason = typeof rawEvent.reason === "string" && rawEvent.reason.trim().length > 0
|
|
918
|
+
? rawEvent.reason.trim()
|
|
919
|
+
: "No explicit reason provided.";
|
|
920
|
+
const from = typeof rawEvent.from_status === "string" ? rawEvent.from_status : "completed";
|
|
921
|
+
const to = typeof rawEvent.to_status === "string" ? rawEvent.to_status : "changes_requested";
|
|
922
|
+
const timestamp = typeof rawEvent.timestamp === "string" ? rawEvent.timestamp : undefined;
|
|
923
|
+
const summary = `Revert detected for ${taskKey}: status moved ${from} -> ${to}. Feedback: ${reason}`;
|
|
924
|
+
return { summary, eventAt: timestamp };
|
|
925
|
+
}
|
|
926
|
+
shouldSavePreference(summary) {
|
|
927
|
+
const lower = summary.toLowerCase();
|
|
928
|
+
return /\b(always|never|prefer|do not|don't|must)\b/.test(lower);
|
|
929
|
+
}
|
|
930
|
+
async persistRevertLearning(taskId, taskKey, metadata, progress, warnings) {
|
|
931
|
+
const learning = this.extractRevertLearning(taskKey, metadata);
|
|
932
|
+
if (!learning)
|
|
933
|
+
return;
|
|
934
|
+
progress.pendingLearningSummary = learning.summary;
|
|
935
|
+
progress.lastRevertEventAt = learning.eventAt ?? new Date().toISOString();
|
|
936
|
+
const gatewayWithLearning = this.deps.gatewayService;
|
|
937
|
+
try {
|
|
938
|
+
if (typeof gatewayWithLearning.saveRepoMemory === "function") {
|
|
939
|
+
await gatewayWithLearning.saveRepoMemory(learning.summary);
|
|
940
|
+
}
|
|
941
|
+
if (this.shouldSavePreference(learning.summary) && typeof gatewayWithLearning.savePreference === "function") {
|
|
942
|
+
await gatewayWithLearning.savePreference("constraint", learning.summary, "codex");
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
catch (error) {
|
|
946
|
+
warnings.push(`Revert learning persistence failed for ${taskKey}: ${error instanceof Error ? error.message : String(error)}`);
|
|
947
|
+
}
|
|
948
|
+
if (metadata) {
|
|
949
|
+
const nextMetadata = { ...metadata, revert_event_pending: false };
|
|
950
|
+
await this.deps.workspaceRepo.updateTask(taskId, { metadata: nextMetadata });
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
sanitizeGoldenText(value) {
|
|
954
|
+
return value
|
|
955
|
+
.replace(/AKIA[0-9A-Z]{16}/g, "[REDACTED_AWS_KEY]")
|
|
956
|
+
.replace(/\bsk-[A-Za-z0-9]{20,}\b/g, "[REDACTED_TOKEN]")
|
|
957
|
+
.replace(/Bearer\s+[A-Za-z0-9._-]+/gi, "Bearer [REDACTED]")
|
|
958
|
+
.trim();
|
|
959
|
+
}
|
|
960
|
+
async appendGoldenExample(entry) {
|
|
961
|
+
const targetPath = path.join(this.workspace.workspaceRoot, GOLDEN_EXAMPLES_REL_PATH);
|
|
962
|
+
await PathHelper.ensureDir(path.dirname(targetPath));
|
|
963
|
+
let existing = [];
|
|
964
|
+
try {
|
|
965
|
+
const raw = await fs.readFile(targetPath, "utf8");
|
|
966
|
+
existing = raw
|
|
967
|
+
.split(/\r?\n/)
|
|
968
|
+
.map((line) => line.trim())
|
|
969
|
+
.filter(Boolean)
|
|
970
|
+
.map((line) => {
|
|
971
|
+
try {
|
|
972
|
+
return JSON.parse(line);
|
|
973
|
+
}
|
|
974
|
+
catch {
|
|
975
|
+
return undefined;
|
|
976
|
+
}
|
|
977
|
+
})
|
|
978
|
+
.filter((item) => Boolean(item));
|
|
979
|
+
}
|
|
980
|
+
catch (error) {
|
|
981
|
+
if (!(error && typeof error === "object" && "code" in error && error.code === "ENOENT")) {
|
|
982
|
+
throw error;
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
const next = {
|
|
986
|
+
intent: this.sanitizeGoldenText(entry.title ? `${entry.taskKey}: ${entry.title}` : entry.taskKey),
|
|
987
|
+
plan_summary: this.sanitizeGoldenText(entry.planSummary),
|
|
988
|
+
touched_files: entry.touchedFiles.map((file) => file.trim()).filter(Boolean),
|
|
989
|
+
review_notes: entry.reviewNotes ? this.sanitizeGoldenText(entry.reviewNotes) : undefined,
|
|
990
|
+
qa_notes: entry.qaNotes ? this.sanitizeGoldenText(entry.qaNotes) : undefined,
|
|
991
|
+
created_at: new Date().toISOString(),
|
|
992
|
+
};
|
|
993
|
+
const bounded = [...existing, next].slice(Math.max(0, existing.length + 1 - GOLDEN_EXAMPLES_MAX));
|
|
994
|
+
const payload = bounded.map((item) => JSON.stringify(item)).join("\n");
|
|
995
|
+
await fs.writeFile(targetPath, payload.length > 0 ? `${payload}\n` : "", "utf8");
|
|
996
|
+
}
|
|
997
|
+
countFailures(progress, step, reason) {
|
|
998
|
+
if (!progress?.failureHistory?.length)
|
|
999
|
+
return 0;
|
|
1000
|
+
return progress.failureHistory.filter((failure) => failure.step === step && failure.reason === reason).length;
|
|
1001
|
+
}
|
|
1002
|
+
prioritizeFeedbackTasks(ordered, state) {
|
|
1003
|
+
const feedback = new Set();
|
|
1004
|
+
for (const progress of Object.values(state.tasks)) {
|
|
1005
|
+
if (progress.lastDecision === "changes_requested") {
|
|
1006
|
+
feedback.add(progress.taskKey);
|
|
1007
|
+
continue;
|
|
1008
|
+
}
|
|
1009
|
+
if (progress.lastOutcome === "fix_required" || progress.lastOutcome === "unclear") {
|
|
1010
|
+
feedback.add(progress.taskKey);
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
if (feedback.size === 0)
|
|
1014
|
+
return ordered;
|
|
1015
|
+
const prioritized = [];
|
|
1016
|
+
const remaining = [];
|
|
1017
|
+
for (const entry of ordered) {
|
|
1018
|
+
if (feedback.has(entry.task.key)) {
|
|
1019
|
+
prioritized.push(entry);
|
|
1020
|
+
}
|
|
1021
|
+
else {
|
|
1022
|
+
remaining.push(entry);
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
return [...prioritized, ...remaining];
|
|
1026
|
+
}
|
|
1027
|
+
buildAgentOptions(progress, step, request) {
|
|
1028
|
+
const reasons = this.escalationReasons(request.escalateOnNoChange !== false);
|
|
1029
|
+
const history = (progress.failureHistory ?? []).filter((failure) => {
|
|
1030
|
+
if (failure.step !== step)
|
|
1031
|
+
return false;
|
|
1032
|
+
const normalized = normalizeFailureReason(failure.reason) ?? failure.reason;
|
|
1033
|
+
return reasons.has(normalized);
|
|
1034
|
+
});
|
|
1035
|
+
const forceStronger = history.length > 0;
|
|
1036
|
+
const forceTier = step === "work" && forceStronger ? this.nextTier(progress.lastWorkAgentTier) : undefined;
|
|
1037
|
+
const avoidAgents = history.length > 1 ? Array.from(new Set(history.map((failure) => failure.agent))) : [];
|
|
1038
|
+
return { avoidAgents, forceStronger, forceTier };
|
|
1039
|
+
}
|
|
1040
|
+
recordRating(progress, summary) {
|
|
1041
|
+
if (!summary)
|
|
1042
|
+
return;
|
|
1043
|
+
const existing = progress.ratings ?? [];
|
|
1044
|
+
const next = existing.filter((entry) => entry.step !== summary.step);
|
|
1045
|
+
next.push(summary);
|
|
1046
|
+
progress.ratings = next;
|
|
1047
|
+
}
|
|
1048
|
+
async loadRatingSummary(jobId, step, agent) {
|
|
1049
|
+
if (!jobId)
|
|
1050
|
+
return undefined;
|
|
1051
|
+
try {
|
|
1052
|
+
const payload = await fs.readFile(path.join(this.workspace.mcodaDir, "jobs", jobId, "rating.json"), "utf8");
|
|
1053
|
+
const parsed = JSON.parse(payload);
|
|
1054
|
+
const rating = typeof parsed.rating === "number" ? parsed.rating : undefined;
|
|
1055
|
+
const maxComplexity = typeof parsed.maxComplexity === "number" ? parsed.maxComplexity : undefined;
|
|
1056
|
+
const runScore = typeof parsed.runScore === "number" ? parsed.runScore : undefined;
|
|
1057
|
+
const qualityScore = typeof parsed.qualityScore === "number" ? parsed.qualityScore : undefined;
|
|
1058
|
+
return { step, agent, rating, maxComplexity, runScore, qualityScore };
|
|
1059
|
+
}
|
|
1060
|
+
catch {
|
|
1061
|
+
return undefined;
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
async updateJobHeartbeat(params) {
|
|
1065
|
+
const timestamp = new Date().toISOString();
|
|
1066
|
+
const detail = `task:${params.taskKey} step:${params.step} attempt:${params.attempt} last:${timestamp}`;
|
|
1067
|
+
try {
|
|
1068
|
+
await this.deps.jobService.updateJobStatus(params.jobId, "running", {
|
|
1069
|
+
job_state_detail: detail,
|
|
1070
|
+
payload: {
|
|
1071
|
+
current_task: params.taskKey,
|
|
1072
|
+
current_step: params.step,
|
|
1073
|
+
attempt: params.attempt,
|
|
1074
|
+
last_activity: timestamp,
|
|
1075
|
+
activity: params.activity ?? "heartbeat",
|
|
1076
|
+
},
|
|
1077
|
+
});
|
|
1078
|
+
}
|
|
1079
|
+
catch {
|
|
1080
|
+
// Avoid failing the run if heartbeat updates cannot be persisted.
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
async runStepWithTimeout(step, jobId, taskKey, attempt, maxAgentSeconds, fn) {
|
|
1084
|
+
await this.updateJobHeartbeat({ jobId, taskKey, step, attempt, activity: "start" });
|
|
1085
|
+
await this.deps.jobService.writeCheckpoint(jobId, {
|
|
1086
|
+
stage: `task:${taskKey}:${step}:start`,
|
|
1087
|
+
timestamp: new Date().toISOString(),
|
|
1088
|
+
details: { taskKey, attempt, step },
|
|
1089
|
+
});
|
|
1090
|
+
const timeoutMs = typeof maxAgentSeconds === "number" && maxAgentSeconds > 0 ? maxAgentSeconds * 1000 : undefined;
|
|
1091
|
+
let timeoutHandle;
|
|
1092
|
+
const controller = new AbortController();
|
|
1093
|
+
const heartbeat = setInterval(() => {
|
|
1094
|
+
void this.updateJobHeartbeat({ jobId, taskKey, step, attempt, activity: "heartbeat" }).catch(() => { });
|
|
1095
|
+
void this.deps.jobService.writeCheckpoint(jobId, {
|
|
1096
|
+
stage: `task:${taskKey}:${step}:heartbeat`,
|
|
1097
|
+
timestamp: new Date().toISOString(),
|
|
1098
|
+
details: { taskKey, attempt, step },
|
|
1099
|
+
});
|
|
1100
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
1101
|
+
if (typeof heartbeat.unref === "function") {
|
|
1102
|
+
heartbeat.unref();
|
|
1103
|
+
}
|
|
1104
|
+
try {
|
|
1105
|
+
if (timeoutMs) {
|
|
1106
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
1107
|
+
timeoutHandle = setTimeout(() => {
|
|
1108
|
+
controller.abort("agent_timeout");
|
|
1109
|
+
reject(new Error("agent_timeout"));
|
|
1110
|
+
}, timeoutMs);
|
|
1111
|
+
});
|
|
1112
|
+
return await Promise.race([fn(controller.signal), timeoutPromise]);
|
|
1113
|
+
}
|
|
1114
|
+
return await fn(controller.signal);
|
|
1115
|
+
}
|
|
1116
|
+
catch (error) {
|
|
1117
|
+
const message = error?.message ?? String(error);
|
|
1118
|
+
return { step, status: "failed", error: message === "agent_timeout" ? "agent_timeout" : message };
|
|
1119
|
+
}
|
|
1120
|
+
finally {
|
|
1121
|
+
if (timeoutHandle)
|
|
1122
|
+
clearTimeout(timeoutHandle);
|
|
1123
|
+
clearInterval(heartbeat);
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
async runGateway(job, taskKey, projectKey, request, agentOptions) {
|
|
1127
|
+
const startedAt = new Date().toISOString();
|
|
1128
|
+
const shouldSuppressIo = Boolean(request.onGatewayStart || request.onGatewayEnd || request.onGatewayChunk);
|
|
1129
|
+
let gatewaySlug = request.gatewayAgentName ?? "auto";
|
|
1130
|
+
let chosenSlug;
|
|
1131
|
+
let chosenModel;
|
|
1132
|
+
let chosenAdapter;
|
|
1133
|
+
let status = "failed";
|
|
1134
|
+
let errorMessage;
|
|
1135
|
+
let startEmitted = false;
|
|
1136
|
+
const invoke = () => this.deps.gatewayService.run({
|
|
1137
|
+
workspace: this.workspace,
|
|
1138
|
+
job,
|
|
1139
|
+
projectKey,
|
|
1140
|
+
taskKeys: [taskKey],
|
|
1141
|
+
gatewayAgentName: request.gatewayAgentName,
|
|
1142
|
+
maxDocs: request.maxDocs,
|
|
1143
|
+
agentStream: request.agentStream,
|
|
1144
|
+
onStreamChunk: request.onGatewayChunk,
|
|
1145
|
+
rateAgents: request.rateAgents,
|
|
1146
|
+
avoidAgents: agentOptions?.avoidAgents,
|
|
1147
|
+
forceStronger: agentOptions?.forceStronger,
|
|
1148
|
+
forceTier: agentOptions?.forceTier,
|
|
1149
|
+
});
|
|
1150
|
+
try {
|
|
1151
|
+
startEmitted = true;
|
|
1152
|
+
request.onGatewayStart?.({
|
|
1153
|
+
taskKey,
|
|
1154
|
+
job,
|
|
1155
|
+
gatewayAgent: gatewaySlug,
|
|
1156
|
+
startedAt,
|
|
1157
|
+
});
|
|
1158
|
+
const result = shouldSuppressIo ? await this.withGatewayIoSuppressed(invoke) : await invoke();
|
|
1159
|
+
gatewaySlug = result.gatewayAgent.slug ?? result.gatewayAgent.id;
|
|
1160
|
+
chosenSlug = result.chosenAgent.agentSlug ?? result.chosenAgent.agentId;
|
|
1161
|
+
try {
|
|
1162
|
+
const resolved = await this.deps.routingService.resolveAgentForCommand({
|
|
1163
|
+
workspace: this.workspace,
|
|
1164
|
+
commandName: job,
|
|
1165
|
+
overrideAgentSlug: chosenSlug,
|
|
1166
|
+
});
|
|
1167
|
+
chosenModel = resolved.model ?? resolved.agent.defaultModel;
|
|
1168
|
+
chosenAdapter = resolved.agent.adapter;
|
|
1169
|
+
}
|
|
1170
|
+
catch {
|
|
1171
|
+
chosenModel = undefined;
|
|
1172
|
+
chosenAdapter = undefined;
|
|
1173
|
+
}
|
|
1174
|
+
request.onGatewaySelection?.({
|
|
1175
|
+
taskKey,
|
|
1176
|
+
job,
|
|
1177
|
+
gatewayAgent: gatewaySlug,
|
|
1178
|
+
chosenAgent: chosenSlug,
|
|
1179
|
+
chosenModel,
|
|
1180
|
+
chosenAdapter,
|
|
1181
|
+
startedAt,
|
|
1182
|
+
});
|
|
1183
|
+
status = "completed";
|
|
1184
|
+
return result;
|
|
1185
|
+
}
|
|
1186
|
+
catch (error) {
|
|
1187
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1188
|
+
errorMessage = message;
|
|
1189
|
+
const wrapped = message ? `${GATEWAY_FAILED_REASON}:${message}` : GATEWAY_FAILED_REASON;
|
|
1190
|
+
throw new Error(wrapped);
|
|
1191
|
+
}
|
|
1192
|
+
finally {
|
|
1193
|
+
if (startEmitted) {
|
|
1194
|
+
request.onGatewayEnd?.({
|
|
1195
|
+
taskKey,
|
|
1196
|
+
job,
|
|
1197
|
+
gatewayAgent: gatewaySlug,
|
|
1198
|
+
chosenAgent: chosenSlug,
|
|
1199
|
+
chosenModel,
|
|
1200
|
+
chosenAdapter,
|
|
1201
|
+
startedAt,
|
|
1202
|
+
endedAt: new Date().toISOString(),
|
|
1203
|
+
status,
|
|
1204
|
+
error: errorMessage,
|
|
1205
|
+
});
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
async withGatewayIoSuppressed(fn) {
|
|
1210
|
+
const ioEnv = "MCODA_STREAM_IO";
|
|
1211
|
+
const promptEnv = "MCODA_STREAM_IO_PROMPT";
|
|
1212
|
+
const prevIo = process.env[ioEnv];
|
|
1213
|
+
const prevPrompt = process.env[promptEnv];
|
|
1214
|
+
process.env[ioEnv] = "0";
|
|
1215
|
+
process.env[promptEnv] = "0";
|
|
1216
|
+
try {
|
|
1217
|
+
return await fn();
|
|
1218
|
+
}
|
|
1219
|
+
finally {
|
|
1220
|
+
if (prevIo === undefined) {
|
|
1221
|
+
delete process.env[ioEnv];
|
|
1222
|
+
}
|
|
1223
|
+
else {
|
|
1224
|
+
process.env[ioEnv] = prevIo;
|
|
1225
|
+
}
|
|
1226
|
+
if (prevPrompt === undefined) {
|
|
1227
|
+
delete process.env[promptEnv];
|
|
1228
|
+
}
|
|
1229
|
+
else {
|
|
1230
|
+
process.env[promptEnv] = prevPrompt;
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
async runWorkStep(jobId, attempt, taskKey, projectKey, statusFilter, request, warnings, agentOptions, abortSignal, handoffContext, onResolvedAgent) {
|
|
1235
|
+
let gateway;
|
|
1236
|
+
let handoff;
|
|
1237
|
+
let resolvedAgent;
|
|
1238
|
+
await this.deps.gatewayService.preflightExecutionAgents("work-on-tasks", request.workAgentName);
|
|
1239
|
+
try {
|
|
1240
|
+
gateway = await this.runGateway("work-on-tasks", taskKey, projectKey, request, agentOptions);
|
|
1241
|
+
resolvedAgent = request.workAgentName ?? gateway.chosenAgent.agentSlug ?? gateway.chosenAgent.agentId;
|
|
1242
|
+
const missingContext = await this.guardMissingContext("work", jobId, taskKey, gateway, warnings, resolvedAgent);
|
|
1243
|
+
if (missingContext) {
|
|
1244
|
+
return missingContext;
|
|
1245
|
+
}
|
|
1246
|
+
handoff = buildGatewayHandoffContent(gateway, handoffContext);
|
|
1247
|
+
}
|
|
1248
|
+
catch (error) {
|
|
1249
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1250
|
+
if (!request.workAgentName)
|
|
1251
|
+
throw error;
|
|
1252
|
+
resolvedAgent = request.workAgentName;
|
|
1253
|
+
handoff = this.appendFallbackHandoffContext([
|
|
1254
|
+
"# Gateway Handoff",
|
|
1255
|
+
"",
|
|
1256
|
+
`Gateway agent failed; proceeding with override agent ${resolvedAgent}.`,
|
|
1257
|
+
`Error: ${message}`,
|
|
1258
|
+
"",
|
|
1259
|
+
buildGatewayHandoffDocdexUsage(),
|
|
1260
|
+
], handoffContext).join("\n");
|
|
1261
|
+
warnings.push(`Gateway agent failed for work ${taskKey}; using override ${resolvedAgent}: ${message}`);
|
|
1262
|
+
}
|
|
1263
|
+
if (!resolvedAgent) {
|
|
1264
|
+
throw new Error(`No agent resolved for work step on ${taskKey}`);
|
|
1265
|
+
}
|
|
1266
|
+
if (onResolvedAgent) {
|
|
1267
|
+
await onResolvedAgent(resolvedAgent);
|
|
1268
|
+
}
|
|
1269
|
+
const selectedTier = this.resolveTierFromComplexity(gateway?.analysis?.complexity);
|
|
1270
|
+
const handoffPath = await this.prepareHandoff(jobId, taskKey, "work", attempt, handoff);
|
|
1271
|
+
const result = await withGatewayHandoff(handoffPath, async () => this.deps.workService.workOnTasks({
|
|
1272
|
+
workspace: this.workspace,
|
|
1273
|
+
projectKey,
|
|
1274
|
+
taskKeys: [taskKey],
|
|
1275
|
+
statusFilter,
|
|
1276
|
+
limit: 1,
|
|
1277
|
+
noCommit: request.noCommit,
|
|
1278
|
+
dryRun: request.dryRun,
|
|
1279
|
+
agentName: resolvedAgent,
|
|
1280
|
+
agentStream: request.agentStream,
|
|
1281
|
+
workRunner: request.workRunner,
|
|
1282
|
+
useCodali: request.useCodali,
|
|
1283
|
+
agentAdapterOverride: request.agentAdapterOverride,
|
|
1284
|
+
rateAgents: request.rateAgents,
|
|
1285
|
+
abortSignal,
|
|
1286
|
+
maxAgentSeconds: request.maxAgentSeconds,
|
|
1287
|
+
}));
|
|
1288
|
+
const parsed = this.parseWorkResult(taskKey, result);
|
|
1289
|
+
const ratingSummary = request.rateAgents
|
|
1290
|
+
? await this.loadRatingSummary(result.jobId, "work", resolvedAgent)
|
|
1291
|
+
: undefined;
|
|
1292
|
+
const zeroTokens = await this.isZeroTokenRun(result.jobId, result.commandRunId);
|
|
1293
|
+
if (zeroTokens) {
|
|
1294
|
+
return {
|
|
1295
|
+
step: "work",
|
|
1296
|
+
status: "failed",
|
|
1297
|
+
error: ZERO_TOKEN_ERROR,
|
|
1298
|
+
chosenAgent: resolvedAgent,
|
|
1299
|
+
ratingSummary,
|
|
1300
|
+
tier: selectedTier,
|
|
1301
|
+
gatewayPlan: gateway?.analysis?.plan ?? [],
|
|
1302
|
+
gatewayFiles: [...(gateway?.analysis?.filesLikelyTouched ?? []), ...(gateway?.analysis?.filesToCreate ?? [])],
|
|
1303
|
+
};
|
|
1304
|
+
}
|
|
1305
|
+
return {
|
|
1306
|
+
...parsed,
|
|
1307
|
+
chosenAgent: resolvedAgent,
|
|
1308
|
+
ratingSummary,
|
|
1309
|
+
tier: selectedTier,
|
|
1310
|
+
gatewayPlan: gateway?.analysis?.plan ?? [],
|
|
1311
|
+
gatewayFiles: [...(gateway?.analysis?.filesLikelyTouched ?? []), ...(gateway?.analysis?.filesToCreate ?? [])],
|
|
1312
|
+
};
|
|
1313
|
+
}
|
|
1314
|
+
async runReviewStep(jobId, attempt, taskKey, projectKey, statusFilter, request, warnings, agentOptions, abortSignal, handoffContext, onResolvedAgent) {
|
|
1315
|
+
let gateway;
|
|
1316
|
+
let handoff;
|
|
1317
|
+
let resolvedAgent;
|
|
1318
|
+
await this.deps.gatewayService.preflightExecutionAgents("code-review", request.reviewAgentName);
|
|
1319
|
+
try {
|
|
1320
|
+
gateway = await this.runGateway("code-review", taskKey, projectKey, request, agentOptions);
|
|
1321
|
+
resolvedAgent = request.reviewAgentName ?? gateway.chosenAgent.agentSlug ?? gateway.chosenAgent.agentId;
|
|
1322
|
+
const missingContext = await this.guardMissingContext("review", jobId, taskKey, gateway, warnings, resolvedAgent);
|
|
1323
|
+
if (missingContext) {
|
|
1324
|
+
return missingContext;
|
|
1325
|
+
}
|
|
1326
|
+
handoff = buildGatewayHandoffContent(gateway, handoffContext);
|
|
1327
|
+
}
|
|
1328
|
+
catch (error) {
|
|
1329
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1330
|
+
if (!request.reviewAgentName)
|
|
1331
|
+
throw error;
|
|
1332
|
+
resolvedAgent = request.reviewAgentName;
|
|
1333
|
+
handoff = this.appendFallbackHandoffContext([
|
|
1334
|
+
"# Gateway Handoff",
|
|
1335
|
+
"",
|
|
1336
|
+
`Routing failed; proceeding with override agent ${resolvedAgent}.`,
|
|
1337
|
+
`Error: ${message}`,
|
|
1338
|
+
"",
|
|
1339
|
+
buildGatewayHandoffDocdexUsage(),
|
|
1340
|
+
], handoffContext).join("\n");
|
|
1341
|
+
warnings.push(`Gateway agent failed for review ${taskKey}; using override ${resolvedAgent}: ${message}`);
|
|
1342
|
+
}
|
|
1343
|
+
if (!resolvedAgent) {
|
|
1344
|
+
throw new Error(`No agent resolved for review step on ${taskKey}`);
|
|
1345
|
+
}
|
|
1346
|
+
if (onResolvedAgent) {
|
|
1347
|
+
await onResolvedAgent(resolvedAgent);
|
|
1348
|
+
}
|
|
1349
|
+
const handoffPath = await this.prepareHandoff(jobId, taskKey, "review", attempt, handoff);
|
|
1350
|
+
const result = await withGatewayHandoff(handoffPath, async () => this.deps.reviewService.reviewTasks({
|
|
1351
|
+
workspace: this.workspace,
|
|
1352
|
+
projectKey,
|
|
1353
|
+
taskKeys: [taskKey],
|
|
1354
|
+
statusFilter,
|
|
1355
|
+
baseRef: request.reviewBase,
|
|
1356
|
+
dryRun: request.dryRun,
|
|
1357
|
+
agentName: resolvedAgent,
|
|
1358
|
+
agentStream: request.agentStream,
|
|
1359
|
+
rateAgents: request.rateAgents,
|
|
1360
|
+
createFollowupTasks: request.reviewFollowups === true,
|
|
1361
|
+
abortSignal,
|
|
1362
|
+
}));
|
|
1363
|
+
const parsed = this.parseReviewResult(taskKey, result);
|
|
1364
|
+
const ratingSummary = request.rateAgents
|
|
1365
|
+
? await this.loadRatingSummary(result.jobId, "review", resolvedAgent)
|
|
1366
|
+
: undefined;
|
|
1367
|
+
const zeroTokens = await this.isZeroTokenRun(result.jobId, result.commandRunId);
|
|
1368
|
+
if (zeroTokens) {
|
|
1369
|
+
return {
|
|
1370
|
+
step: "review",
|
|
1371
|
+
status: "failed",
|
|
1372
|
+
error: ZERO_TOKEN_ERROR,
|
|
1373
|
+
chosenAgent: resolvedAgent,
|
|
1374
|
+
ratingSummary,
|
|
1375
|
+
gatewayPlan: gateway?.analysis?.plan ?? [],
|
|
1376
|
+
gatewayFiles: [...(gateway?.analysis?.filesLikelyTouched ?? []), ...(gateway?.analysis?.filesToCreate ?? [])],
|
|
1377
|
+
};
|
|
1378
|
+
}
|
|
1379
|
+
return {
|
|
1380
|
+
...parsed,
|
|
1381
|
+
chosenAgent: resolvedAgent,
|
|
1382
|
+
ratingSummary,
|
|
1383
|
+
gatewayPlan: gateway?.analysis?.plan ?? [],
|
|
1384
|
+
gatewayFiles: [...(gateway?.analysis?.filesLikelyTouched ?? []), ...(gateway?.analysis?.filesToCreate ?? [])],
|
|
1385
|
+
};
|
|
1386
|
+
}
|
|
1387
|
+
async runQaStep(jobId, attempt, taskKey, projectKey, statusFilter, request, warnings, agentOptions, abortSignal, handoffContext, onResolvedAgent) {
|
|
1388
|
+
let gateway;
|
|
1389
|
+
let handoff;
|
|
1390
|
+
let resolvedAgent;
|
|
1391
|
+
await this.deps.gatewayService.preflightExecutionAgents("qa-tasks", request.qaAgentName);
|
|
1392
|
+
try {
|
|
1393
|
+
gateway = await this.runGateway("qa-tasks", taskKey, projectKey, request, agentOptions);
|
|
1394
|
+
resolvedAgent = request.qaAgentName ?? gateway.chosenAgent.agentSlug ?? gateway.chosenAgent.agentId;
|
|
1395
|
+
const missingContext = await this.guardMissingContext("qa", jobId, taskKey, gateway, warnings, resolvedAgent);
|
|
1396
|
+
if (missingContext) {
|
|
1397
|
+
return missingContext;
|
|
1398
|
+
}
|
|
1399
|
+
handoff = buildGatewayHandoffContent(gateway, handoffContext);
|
|
1400
|
+
}
|
|
1401
|
+
catch (error) {
|
|
1402
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1403
|
+
if (!request.qaAgentName)
|
|
1404
|
+
throw error;
|
|
1405
|
+
resolvedAgent = request.qaAgentName;
|
|
1406
|
+
handoff = this.appendFallbackHandoffContext([
|
|
1407
|
+
"# Gateway Handoff",
|
|
1408
|
+
"",
|
|
1409
|
+
`Routing failed; proceeding with override agent ${resolvedAgent}.`,
|
|
1410
|
+
`Error: ${message}`,
|
|
1411
|
+
"",
|
|
1412
|
+
buildGatewayHandoffDocdexUsage(),
|
|
1413
|
+
], handoffContext).join("\n");
|
|
1414
|
+
warnings.push(`Gateway agent failed for QA ${taskKey}; using override ${resolvedAgent}: ${message}`);
|
|
1415
|
+
}
|
|
1416
|
+
if (!resolvedAgent) {
|
|
1417
|
+
throw new Error(`No agent resolved for QA step on ${taskKey}`);
|
|
1418
|
+
}
|
|
1419
|
+
if (onResolvedAgent) {
|
|
1420
|
+
await onResolvedAgent(resolvedAgent);
|
|
1421
|
+
}
|
|
1422
|
+
const handoffPath = await this.prepareHandoff(jobId, taskKey, "qa", attempt, handoff);
|
|
1423
|
+
const result = await withGatewayHandoff(handoffPath, async () => this.deps.qaService.run({
|
|
1424
|
+
workspace: this.workspace,
|
|
1425
|
+
projectKey,
|
|
1426
|
+
taskKeys: [taskKey],
|
|
1427
|
+
statusFilter,
|
|
1428
|
+
mode: request.qaMode ?? "auto",
|
|
1429
|
+
profileName: request.qaProfileName,
|
|
1430
|
+
level: request.qaLevel,
|
|
1431
|
+
testCommand: request.qaTestCommand,
|
|
1432
|
+
agentName: resolvedAgent,
|
|
1433
|
+
agentStream: request.agentStream,
|
|
1434
|
+
rateAgents: request.rateAgents,
|
|
1435
|
+
createFollowupTasks: request.qaFollowups ?? "auto",
|
|
1436
|
+
dryRun: request.dryRun,
|
|
1437
|
+
result: request.qaResult,
|
|
1438
|
+
notes: request.qaNotes,
|
|
1439
|
+
evidenceUrl: request.qaEvidenceUrl,
|
|
1440
|
+
allowDirty: request.qaAllowDirty,
|
|
1441
|
+
abortSignal,
|
|
1442
|
+
}));
|
|
1443
|
+
const parsed = this.parseQaResult(taskKey, result);
|
|
1444
|
+
const ratingSummary = request.rateAgents
|
|
1445
|
+
? await this.loadRatingSummary(result.jobId, "qa", resolvedAgent)
|
|
1446
|
+
: undefined;
|
|
1447
|
+
const zeroTokens = await this.isZeroTokenRun(result.jobId, result.commandRunId);
|
|
1448
|
+
if (zeroTokens) {
|
|
1449
|
+
return {
|
|
1450
|
+
step: "qa",
|
|
1451
|
+
status: "failed",
|
|
1452
|
+
error: ZERO_TOKEN_ERROR,
|
|
1453
|
+
chosenAgent: resolvedAgent,
|
|
1454
|
+
ratingSummary,
|
|
1455
|
+
gatewayPlan: gateway?.analysis?.plan ?? [],
|
|
1456
|
+
gatewayFiles: [...(gateway?.analysis?.filesLikelyTouched ?? []), ...(gateway?.analysis?.filesToCreate ?? [])],
|
|
1457
|
+
};
|
|
1458
|
+
}
|
|
1459
|
+
return {
|
|
1460
|
+
...parsed,
|
|
1461
|
+
chosenAgent: resolvedAgent,
|
|
1462
|
+
ratingSummary,
|
|
1463
|
+
gatewayPlan: gateway?.analysis?.plan ?? [],
|
|
1464
|
+
gatewayFiles: [...(gateway?.analysis?.filesLikelyTouched ?? []), ...(gateway?.analysis?.filesToCreate ?? [])],
|
|
1465
|
+
};
|
|
1466
|
+
}
|
|
1467
|
+
toSummary(state) {
|
|
1468
|
+
return Object.values(state.tasks).map((task) => ({
|
|
1469
|
+
taskKey: task.taskKey,
|
|
1470
|
+
attempts: task.attempts,
|
|
1471
|
+
status: task.status,
|
|
1472
|
+
lastStep: task.lastStep,
|
|
1473
|
+
lastDecision: task.lastDecision,
|
|
1474
|
+
lastOutcome: task.lastOutcome,
|
|
1475
|
+
lastError: task.lastError,
|
|
1476
|
+
chosenAgents: task.chosenAgents,
|
|
1477
|
+
ratings: task.ratings,
|
|
1478
|
+
}));
|
|
1479
|
+
}
|
|
1480
|
+
async run(request) {
|
|
1481
|
+
const warnings = [];
|
|
1482
|
+
let resumeJob;
|
|
1483
|
+
if (request.resumeJobId) {
|
|
1484
|
+
const job = await this.deps.jobService.getJob(request.resumeJobId);
|
|
1485
|
+
if (!job)
|
|
1486
|
+
throw new Error(`Job not found: ${request.resumeJobId}`);
|
|
1487
|
+
const manifest = await this.readManifest(job.id);
|
|
1488
|
+
this.assertResumeAllowed(job, manifest);
|
|
1489
|
+
const command = (job.commandName ?? job.type ?? "").toLowerCase();
|
|
1490
|
+
if (command !== "gateway-trio") {
|
|
1491
|
+
throw new Error(`Job ${request.resumeJobId} is not a gateway-trio job`);
|
|
1492
|
+
}
|
|
1493
|
+
resumeJob = job;
|
|
1494
|
+
}
|
|
1495
|
+
const resolvedRequest = this.resolveRequest(request, resumeJob?.payload);
|
|
1496
|
+
const maxIterations = resolvedRequest.maxIterations;
|
|
1497
|
+
const continuousMode = maxIterations === undefined;
|
|
1498
|
+
const maxCycles = resolvedRequest.maxCycles;
|
|
1499
|
+
const maxAgentSeconds = resolvedRequest.maxAgentSeconds;
|
|
1500
|
+
if (!resolvedRequest.rateAgents) {
|
|
1501
|
+
warnings.push("Agent rating disabled; use --rate-agents to track rating/complexity updates.");
|
|
1502
|
+
}
|
|
1503
|
+
const statusFilter = await this.buildStatusFilter(resolvedRequest, warnings);
|
|
1504
|
+
const explicitTaskKeys = resolvedRequest.taskKeys ? this.dedupeTaskKeys(resolvedRequest.taskKeys) : [];
|
|
1505
|
+
const pseudoTaskKeys = explicitTaskKeys.filter((key) => this.isPseudoTaskKey(key));
|
|
1506
|
+
const filteredTaskKeys = explicitTaskKeys.filter((key) => !this.isPseudoTaskKey(key));
|
|
1507
|
+
const explicitTaskKeysProvided = explicitTaskKeys.length > 0;
|
|
1508
|
+
const includeTypes = resolvedRequest.includeTypes?.length ? resolvedRequest.includeTypes : undefined;
|
|
1509
|
+
let excludeTypes = resolvedRequest.excludeTypes;
|
|
1510
|
+
if (!excludeTypes && !includeTypes?.length) {
|
|
1511
|
+
excludeTypes = ["qa_followup"];
|
|
1512
|
+
}
|
|
1513
|
+
const explicitTaskFilterEmpty = explicitTaskKeysProvided && filteredTaskKeys.length === 0;
|
|
1514
|
+
if (pseudoTaskKeys.length) {
|
|
1515
|
+
warnings.push(`Skipping pseudo tasks: ${pseudoTaskKeys.join(", ")}.`);
|
|
1516
|
+
}
|
|
1517
|
+
if (explicitTaskFilterEmpty) {
|
|
1518
|
+
warnings.push("All requested tasks were pseudo entries; nothing to run.");
|
|
1519
|
+
}
|
|
1520
|
+
const baseTaskKeys = explicitTaskKeysProvided ? filteredTaskKeys : resolvedRequest.taskKeys;
|
|
1521
|
+
const shouldFixRunList = explicitTaskKeysProvided || (typeof resolvedRequest.limit === "number" && resolvedRequest.limit > 0);
|
|
1522
|
+
let jobId = request.resumeJobId;
|
|
1523
|
+
let state;
|
|
1524
|
+
let runList;
|
|
1525
|
+
if (request.resumeJobId) {
|
|
1526
|
+
state = await this.loadState(request.resumeJobId);
|
|
1527
|
+
if (!state)
|
|
1528
|
+
throw new Error(`Missing gateway-trio state for job ${request.resumeJobId}`);
|
|
1529
|
+
if (shouldFixRunList) {
|
|
1530
|
+
runList = state.run_list;
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
if (shouldFixRunList && !runList) {
|
|
1534
|
+
if (explicitTaskFilterEmpty) {
|
|
1535
|
+
runList = [];
|
|
1536
|
+
}
|
|
1537
|
+
else if (explicitTaskKeysProvided) {
|
|
1538
|
+
runList = this.buildRunListFromExplicit(filteredTaskKeys, resolvedRequest.limit, warnings);
|
|
1539
|
+
}
|
|
1540
|
+
else {
|
|
1541
|
+
runList = await this.buildRunListFromSelection({
|
|
1542
|
+
projectKey: resolvedRequest.projectKey,
|
|
1543
|
+
epicKey: resolvedRequest.epicKey,
|
|
1544
|
+
storyKey: resolvedRequest.storyKey,
|
|
1545
|
+
taskKeys: baseTaskKeys,
|
|
1546
|
+
statusFilter,
|
|
1547
|
+
includeTypes,
|
|
1548
|
+
excludeTypes,
|
|
1549
|
+
limit: resolvedRequest.limit,
|
|
1550
|
+
parallel: resolvedRequest.parallel,
|
|
1551
|
+
}, resolvedRequest.limit, warnings);
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
const taskKeysForRun = runList ?? baseTaskKeys;
|
|
1555
|
+
const commandRun = await this.deps.jobService.startCommandRun("gateway-trio", resolvedRequest.projectKey, {
|
|
1556
|
+
taskIds: taskKeysForRun,
|
|
1557
|
+
jobId: request.resumeJobId,
|
|
1558
|
+
});
|
|
1559
|
+
if (request.resumeJobId) {
|
|
1560
|
+
await this.deps.jobService.updateJobStatus(request.resumeJobId, "running", {
|
|
1561
|
+
job_state_detail: "resuming",
|
|
1562
|
+
});
|
|
1563
|
+
if (shouldFixRunList && runList && state && !state.run_list) {
|
|
1564
|
+
state.run_list = runList;
|
|
1565
|
+
await this.writeState(state);
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
else {
|
|
1569
|
+
const job = await this.deps.jobService.startJob("gateway-trio", commandRun.id, resolvedRequest.projectKey, {
|
|
1570
|
+
commandName: "gateway-trio",
|
|
1571
|
+
payload: {
|
|
1572
|
+
projectKey: resolvedRequest.projectKey,
|
|
1573
|
+
epicKey: resolvedRequest.epicKey,
|
|
1574
|
+
storyKey: resolvedRequest.storyKey,
|
|
1575
|
+
tasks: taskKeysForRun,
|
|
1576
|
+
statusFilter,
|
|
1577
|
+
maxIterations,
|
|
1578
|
+
maxCycles,
|
|
1579
|
+
limit: resolvedRequest.limit,
|
|
1580
|
+
parallel: resolvedRequest.parallel,
|
|
1581
|
+
gatewayAgentName: resolvedRequest.gatewayAgentName,
|
|
1582
|
+
workAgentName: resolvedRequest.workAgentName,
|
|
1583
|
+
reviewAgentName: resolvedRequest.reviewAgentName,
|
|
1584
|
+
qaAgentName: resolvedRequest.qaAgentName,
|
|
1585
|
+
maxDocs: resolvedRequest.maxDocs,
|
|
1586
|
+
agentStream: resolvedRequest.agentStream,
|
|
1587
|
+
noCommit: resolvedRequest.noCommit,
|
|
1588
|
+
dryRun: resolvedRequest.dryRun,
|
|
1589
|
+
reviewBase: resolvedRequest.reviewBase,
|
|
1590
|
+
maxAgentSeconds,
|
|
1591
|
+
qaProfileName: resolvedRequest.qaProfileName,
|
|
1592
|
+
qaLevel: resolvedRequest.qaLevel,
|
|
1593
|
+
qaTestCommand: resolvedRequest.qaTestCommand,
|
|
1594
|
+
qaMode: resolvedRequest.qaMode,
|
|
1595
|
+
qaFollowups: resolvedRequest.qaFollowups,
|
|
1596
|
+
reviewFollowups: resolvedRequest.reviewFollowups,
|
|
1597
|
+
qaResult: resolvedRequest.qaResult,
|
|
1598
|
+
qaNotes: resolvedRequest.qaNotes,
|
|
1599
|
+
qaEvidenceUrl: resolvedRequest.qaEvidenceUrl,
|
|
1600
|
+
qaAllowDirty: resolvedRequest.qaAllowDirty,
|
|
1601
|
+
escalateOnNoChange: resolvedRequest.escalateOnNoChange,
|
|
1602
|
+
resumeSupported: true,
|
|
1603
|
+
},
|
|
1604
|
+
});
|
|
1605
|
+
jobId = job.id;
|
|
1606
|
+
state = {
|
|
1607
|
+
schema_version: 1,
|
|
1608
|
+
job_id: job.id,
|
|
1609
|
+
command_run_id: commandRun.id,
|
|
1610
|
+
run_list: runList,
|
|
1611
|
+
cycle: 0,
|
|
1612
|
+
tasks: {},
|
|
1613
|
+
};
|
|
1614
|
+
await this.writeState(state);
|
|
1615
|
+
await this.deps.jobService.updateJobStatus(jobId, "running", {
|
|
1616
|
+
job_state_detail: "loading_tasks",
|
|
1617
|
+
});
|
|
1618
|
+
}
|
|
1619
|
+
if (!jobId || !state) {
|
|
1620
|
+
throw new Error("gateway-trio job initialization failed");
|
|
1621
|
+
}
|
|
1622
|
+
if (resolvedRequest.onJobStart) {
|
|
1623
|
+
resolvedRequest.onJobStart(jobId, commandRun.id);
|
|
1624
|
+
}
|
|
1625
|
+
await this.runDocdexPreflight(jobId, warnings);
|
|
1626
|
+
await this.cleanupExpiredTaskLocks(warnings);
|
|
1627
|
+
const explicitTasks = new Set(explicitTaskKeysProvided ? (runList ?? filteredTaskKeys) : []);
|
|
1628
|
+
if (pseudoTaskKeys.length) {
|
|
1629
|
+
for (const key of pseudoTaskKeys) {
|
|
1630
|
+
this.skipPseudoTask(state, key, warnings);
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
await this.seedExplicitTasks(state, explicitTasks, warnings);
|
|
1634
|
+
await this.writeState(state);
|
|
1635
|
+
let cycle = state.cycle ?? 0;
|
|
1636
|
+
const taskLimit = resolvedRequest.limit;
|
|
1637
|
+
let activeTaskKey = null;
|
|
1638
|
+
let abortRemainingReason = null;
|
|
1639
|
+
const resolveJobState = (job) => job?.jobState ?? job?.state;
|
|
1640
|
+
const checkCancellation = async (label) => {
|
|
1641
|
+
const job = await this.deps.jobService.getJob(jobId);
|
|
1642
|
+
const stateNow = resolveJobState(job);
|
|
1643
|
+
if (stateNow === "cancelled") {
|
|
1644
|
+
abortRemainingReason = "cancelled";
|
|
1645
|
+
warnings.push(`Job ${jobId} cancelled${label ? ` (${label})` : ""}; stopping.`);
|
|
1646
|
+
return true;
|
|
1647
|
+
}
|
|
1648
|
+
return false;
|
|
1649
|
+
};
|
|
1650
|
+
try {
|
|
1651
|
+
while ((maxCycles === undefined || cycle < maxCycles) && !abortRemainingReason) {
|
|
1652
|
+
if (await checkCancellation("cycle_start"))
|
|
1653
|
+
break;
|
|
1654
|
+
await this.reopenRetryableFailedTasks(state, explicitTasks, maxIterations, warnings);
|
|
1655
|
+
await this.writeState(state);
|
|
1656
|
+
const selection = explicitTaskFilterEmpty || (Array.isArray(runList) && runList.length === 0)
|
|
1657
|
+
? {
|
|
1658
|
+
ordered: [],
|
|
1659
|
+
warnings: [],
|
|
1660
|
+
filters: { effectiveStatuses: [] },
|
|
1661
|
+
}
|
|
1662
|
+
: await this.selectionService.selectTasks({
|
|
1663
|
+
projectKey: resolvedRequest.projectKey,
|
|
1664
|
+
epicKey: resolvedRequest.epicKey,
|
|
1665
|
+
storyKey: resolvedRequest.storyKey,
|
|
1666
|
+
taskKeys: Array.isArray(runList) ? runList : taskKeysForRun,
|
|
1667
|
+
statusFilter,
|
|
1668
|
+
limit: resolvedRequest.limit,
|
|
1669
|
+
parallel: resolvedRequest.parallel,
|
|
1670
|
+
});
|
|
1671
|
+
if (selection.warnings.length)
|
|
1672
|
+
warnings.push(...selection.warnings);
|
|
1673
|
+
const completedKeys = new Set(Object.values(state.tasks)
|
|
1674
|
+
.filter((task) => task.status === "completed")
|
|
1675
|
+
.map((task) => task.taskKey));
|
|
1676
|
+
let orderedCandidates = this.prioritizeFeedbackTasks(selection.ordered, state);
|
|
1677
|
+
if (activeTaskKey) {
|
|
1678
|
+
const activeIndex = orderedCandidates.findIndex((entry) => entry.task.key === activeTaskKey);
|
|
1679
|
+
if (activeIndex >= 0) {
|
|
1680
|
+
const activeEntry = orderedCandidates[activeIndex];
|
|
1681
|
+
orderedCandidates = [
|
|
1682
|
+
activeEntry,
|
|
1683
|
+
...orderedCandidates.slice(0, activeIndex),
|
|
1684
|
+
...orderedCandidates.slice(activeIndex + 1),
|
|
1685
|
+
];
|
|
1686
|
+
}
|
|
1687
|
+
else {
|
|
1688
|
+
const activeSelection = await this.selectionService.selectTasks({
|
|
1689
|
+
taskKeys: [activeTaskKey],
|
|
1690
|
+
ignoreStatusFilter: true,
|
|
1691
|
+
limit: 1,
|
|
1692
|
+
parallel: resolvedRequest.parallel,
|
|
1693
|
+
});
|
|
1694
|
+
const activeEntry = activeSelection.ordered[0];
|
|
1695
|
+
if (activeEntry) {
|
|
1696
|
+
orderedCandidates = [activeEntry, ...orderedCandidates];
|
|
1697
|
+
}
|
|
1698
|
+
else {
|
|
1699
|
+
activeTaskKey = null;
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
}
|
|
1703
|
+
const ordered = [];
|
|
1704
|
+
const seenOrdered = new Set();
|
|
1705
|
+
for (const entry of orderedCandidates) {
|
|
1706
|
+
const taskKey = entry.task.key;
|
|
1707
|
+
if (seenOrdered.has(taskKey)) {
|
|
1708
|
+
warnings.push(`Task ${taskKey} appears multiple times in this cycle; skipping duplicate entry.`);
|
|
1709
|
+
continue;
|
|
1710
|
+
}
|
|
1711
|
+
seenOrdered.add(taskKey);
|
|
1712
|
+
const statusNow = this.normalizeStatus(entry.task.status);
|
|
1713
|
+
if (statusNow === "cancelled") {
|
|
1714
|
+
const progress = this.ensureProgress(state, taskKey);
|
|
1715
|
+
progress.status = "skipped";
|
|
1716
|
+
progress.lastError = "cancelled_in_db";
|
|
1717
|
+
state.tasks[taskKey] = progress;
|
|
1718
|
+
warnings.push(`Task ${taskKey} is cancelled; skipping.`);
|
|
1719
|
+
if (activeTaskKey === taskKey) {
|
|
1720
|
+
activeTaskKey = null;
|
|
1721
|
+
}
|
|
1722
|
+
continue;
|
|
1723
|
+
}
|
|
1724
|
+
if (completedKeys.has(taskKey)) {
|
|
1725
|
+
warnings.push(`Task ${taskKey} already completed earlier in this run; skipping.`);
|
|
1726
|
+
if (activeTaskKey === taskKey) {
|
|
1727
|
+
activeTaskKey = null;
|
|
1728
|
+
}
|
|
1729
|
+
continue;
|
|
1730
|
+
}
|
|
1731
|
+
if (this.isPseudoTaskKey(taskKey)) {
|
|
1732
|
+
this.skipPseudoTask(state, taskKey, warnings);
|
|
1733
|
+
if (activeTaskKey === taskKey) {
|
|
1734
|
+
activeTaskKey = null;
|
|
1735
|
+
}
|
|
1736
|
+
continue;
|
|
1737
|
+
}
|
|
1738
|
+
ordered.push(entry);
|
|
1739
|
+
}
|
|
1740
|
+
await this.writeState(state);
|
|
1741
|
+
await this.deps.jobService.updateJobStatus(jobId, "running", {
|
|
1742
|
+
totalItems: ordered.length,
|
|
1743
|
+
processedItems: 0,
|
|
1744
|
+
job_state_detail: ordered.length === 0 ? "no_tasks" : "processing",
|
|
1745
|
+
});
|
|
1746
|
+
let completedThisCycle = 0;
|
|
1747
|
+
let processedThisCycle = 0;
|
|
1748
|
+
let attemptedThisCycle = 0;
|
|
1749
|
+
const seenThisCycle = new Set();
|
|
1750
|
+
for (const entry of ordered) {
|
|
1751
|
+
if (abortRemainingReason)
|
|
1752
|
+
break;
|
|
1753
|
+
if (await checkCancellation(`task:${entry.task.key}`))
|
|
1754
|
+
break;
|
|
1755
|
+
if (typeof taskLimit === "number" && taskLimit > 0 && completedKeys.size >= taskLimit) {
|
|
1756
|
+
warnings.push(`Completed task limit ${taskLimit} reached; stopping run.`);
|
|
1757
|
+
abortRemainingReason = "limit_reached";
|
|
1758
|
+
break;
|
|
1759
|
+
}
|
|
1760
|
+
if (activeTaskKey && entry.task.key !== activeTaskKey) {
|
|
1761
|
+
const activeProgress = state.tasks[activeTaskKey];
|
|
1762
|
+
if (activeProgress && activeProgress.status !== "completed") {
|
|
1763
|
+
break;
|
|
1764
|
+
}
|
|
1765
|
+
activeTaskKey = null;
|
|
1766
|
+
}
|
|
1767
|
+
let attempted = false;
|
|
1768
|
+
let holdAfterTask = false;
|
|
1769
|
+
let currentTaskKey;
|
|
1770
|
+
try {
|
|
1771
|
+
const taskKey = entry.task.key;
|
|
1772
|
+
currentTaskKey = taskKey;
|
|
1773
|
+
if (seenThisCycle.has(taskKey)) {
|
|
1774
|
+
warnings.push(`Task ${taskKey} appears multiple times in this cycle; skipping duplicate entry.`);
|
|
1775
|
+
continue;
|
|
1776
|
+
}
|
|
1777
|
+
seenThisCycle.add(taskKey);
|
|
1778
|
+
if (completedKeys.has(taskKey)) {
|
|
1779
|
+
warnings.push(`Task ${taskKey} already completed earlier in this run; skipping.`);
|
|
1780
|
+
continue;
|
|
1781
|
+
}
|
|
1782
|
+
if (typeof taskLimit === "number" && taskLimit > 0 && completedKeys.size >= taskLimit) {
|
|
1783
|
+
warnings.push(`Completed task limit ${taskLimit} reached; stopping run.`);
|
|
1784
|
+
abortRemainingReason = "limit_reached";
|
|
1785
|
+
break;
|
|
1786
|
+
}
|
|
1787
|
+
const normalizedStatus = this.normalizeStatus(entry.task.status);
|
|
1788
|
+
if (normalizedStatus && TERMINAL_STATUSES.has(normalizedStatus)) {
|
|
1789
|
+
warnings.push(`Skipping terminal task ${taskKey} (${normalizedStatus}).`);
|
|
1790
|
+
continue;
|
|
1791
|
+
}
|
|
1792
|
+
const progress = this.ensureProgress(state, taskKey);
|
|
1793
|
+
await this.persistRevertLearning(entry.task.id, taskKey, entry.task.metadata ?? undefined, progress, warnings);
|
|
1794
|
+
state.tasks[taskKey] = progress;
|
|
1795
|
+
if (progress.status === "skipped") {
|
|
1796
|
+
progress.status = "pending";
|
|
1797
|
+
progress.lastError = undefined;
|
|
1798
|
+
}
|
|
1799
|
+
if (progress.status === "completed") {
|
|
1800
|
+
continue;
|
|
1801
|
+
}
|
|
1802
|
+
if (progress.status === "failed") {
|
|
1803
|
+
if (progress.lastError === ZERO_TOKEN_ERROR && !continuousMode) {
|
|
1804
|
+
warnings.push(`Task ${taskKey} failed after repeated zero-token runs.`);
|
|
1805
|
+
continue;
|
|
1806
|
+
}
|
|
1807
|
+
const normalizedReason = normalizeFailureReason(progress.lastError ?? progress.failureHistory?.[progress.failureHistory.length - 1]?.reason ?? "");
|
|
1808
|
+
if (!continuousMode &&
|
|
1809
|
+
(progress.lastGuardrailRetryable === false ||
|
|
1810
|
+
(normalizedReason && NON_RETRYABLE_FAILURE_REASONS.has(normalizedReason)))) {
|
|
1811
|
+
const reasonLabel = normalizedReason ?? progress.lastError ?? progress.lastEscalationReason ?? "guardrail_non_retryable";
|
|
1812
|
+
warnings.push(`Task ${taskKey} failed with non-retryable reason ${reasonLabel}; skipping.`);
|
|
1813
|
+
continue;
|
|
1814
|
+
}
|
|
1815
|
+
if (this.hasReachedMaxIterations(progress, maxIterations)) {
|
|
1816
|
+
continue;
|
|
1817
|
+
}
|
|
1818
|
+
if (!this.shouldReopenFailedTask(progress, taskKey, warnings, continuousMode)) {
|
|
1819
|
+
continue;
|
|
1820
|
+
}
|
|
1821
|
+
progress.status = "pending";
|
|
1822
|
+
progress.lastError = undefined;
|
|
1823
|
+
progress.lastGuardrailRetryable = undefined;
|
|
1824
|
+
state.tasks[taskKey] = progress;
|
|
1825
|
+
}
|
|
1826
|
+
const projectKey = await this.projectKeyForTask(entry.task.projectId);
|
|
1827
|
+
let currentStatus = this.normalizeStatus(entry.task.status);
|
|
1828
|
+
const readStatus = async () => {
|
|
1829
|
+
if (resolvedRequest.dryRun)
|
|
1830
|
+
return currentStatus;
|
|
1831
|
+
return await this.refreshTaskStatus(taskKey, warnings);
|
|
1832
|
+
};
|
|
1833
|
+
const setDryRunStatus = (next) => {
|
|
1834
|
+
if (resolvedRequest.dryRun) {
|
|
1835
|
+
currentStatus = next;
|
|
1836
|
+
}
|
|
1837
|
+
};
|
|
1838
|
+
let taskCompleted = false;
|
|
1839
|
+
while (!taskCompleted) {
|
|
1840
|
+
const statusNow = await readStatus();
|
|
1841
|
+
if (!statusNow) {
|
|
1842
|
+
progress.status = "failed";
|
|
1843
|
+
progress.lastError = "status_unknown";
|
|
1844
|
+
state.tasks[taskKey] = progress;
|
|
1845
|
+
await this.writeState(state);
|
|
1846
|
+
warnings.push(`Task ${taskKey} status unknown; skipping.`);
|
|
1847
|
+
break;
|
|
1848
|
+
}
|
|
1849
|
+
if (TERMINAL_STATUSES.has(statusNow)) {
|
|
1850
|
+
progress.status = statusNow === "completed" ? "completed" : "skipped";
|
|
1851
|
+
progress.lastError = statusNow === "completed" ? "completed_in_db" : "cancelled_in_db";
|
|
1852
|
+
state.tasks[taskKey] = progress;
|
|
1853
|
+
await this.writeState(state);
|
|
1854
|
+
if (statusNow === "completed") {
|
|
1855
|
+
completedKeys.add(taskKey);
|
|
1856
|
+
completedThisCycle += 1;
|
|
1857
|
+
}
|
|
1858
|
+
break;
|
|
1859
|
+
}
|
|
1860
|
+
if (statusNow === "blocked") {
|
|
1861
|
+
progress.status = "failed";
|
|
1862
|
+
progress.lastError = "legacy_blocked";
|
|
1863
|
+
state.tasks[taskKey] = progress;
|
|
1864
|
+
if (!resolvedRequest.dryRun) {
|
|
1865
|
+
await this.deps.workspaceRepo.updateTask(entry.task.id, {
|
|
1866
|
+
status: "failed",
|
|
1867
|
+
metadata: {
|
|
1868
|
+
...entry.task.metadata,
|
|
1869
|
+
failed_reason: "legacy_blocked",
|
|
1870
|
+
},
|
|
1871
|
+
});
|
|
1872
|
+
}
|
|
1873
|
+
await this.writeState(state);
|
|
1874
|
+
warnings.push(`Task ${taskKey} had legacy blocked status; marked failed.`);
|
|
1875
|
+
break;
|
|
1876
|
+
}
|
|
1877
|
+
if (!attempted) {
|
|
1878
|
+
attemptedThisCycle += 1;
|
|
1879
|
+
attempted = true;
|
|
1880
|
+
}
|
|
1881
|
+
if (isReadyToReviewStatus(statusNow)) {
|
|
1882
|
+
const attemptIndex = Math.max(progress.attempts, 1);
|
|
1883
|
+
const reviewAgentOptions = this.buildAgentOptions(progress, "review", resolvedRequest);
|
|
1884
|
+
progress.lastStep = "review";
|
|
1885
|
+
state.tasks[taskKey] = progress;
|
|
1886
|
+
await this.writeState(state);
|
|
1887
|
+
const reviewOutcome = await this.runStepWithTimeout("review", jobId, taskKey, attemptIndex, maxAgentSeconds, (signal) => this.runReviewStep(jobId, attemptIndex, taskKey, projectKey, normalizeReviewStatuses([READY_TO_CODE_REVIEW]), resolvedRequest, warnings, reviewAgentOptions, signal, this.handoffContextForProgress(progress), async (agent) => {
|
|
1888
|
+
progress.chosenAgents.review = agent;
|
|
1889
|
+
state.tasks[taskKey] = progress;
|
|
1890
|
+
await this.writeState(state);
|
|
1891
|
+
}));
|
|
1892
|
+
progress.lastStep = "review";
|
|
1893
|
+
progress.lastDecision = reviewOutcome.decision;
|
|
1894
|
+
progress.lastError = reviewOutcome.error;
|
|
1895
|
+
progress.chosenAgents.review = reviewOutcome.chosenAgent ?? progress.chosenAgents.review;
|
|
1896
|
+
if (reviewOutcome.gatewayPlan?.length) {
|
|
1897
|
+
progress.lastOutcome = reviewOutcome.gatewayPlan.join(" | ");
|
|
1898
|
+
}
|
|
1899
|
+
this.recordDecision(progress, "review", attemptIndex, "result", reviewOutcome.status, reviewOutcome.decision ?? reviewOutcome.error ?? reviewOutcome.outcome);
|
|
1900
|
+
if (this.isAuthFailure(reviewOutcome.error)) {
|
|
1901
|
+
const message = reviewOutcome.error ?? AUTH_ERROR_REASON;
|
|
1902
|
+
reviewOutcome.error = AUTH_ERROR_REASON;
|
|
1903
|
+
progress.lastError = AUTH_ERROR_REASON;
|
|
1904
|
+
this.recordFailure(progress, reviewOutcome, attemptIndex);
|
|
1905
|
+
progress.status = "failed";
|
|
1906
|
+
state.tasks[taskKey] = progress;
|
|
1907
|
+
await this.writeState(state);
|
|
1908
|
+
if (!resolvedRequest.dryRun) {
|
|
1909
|
+
await this.deps.workspaceRepo.updateTask(entry.task.id, {
|
|
1910
|
+
status: "failed",
|
|
1911
|
+
metadata: {
|
|
1912
|
+
...entry.task.metadata,
|
|
1913
|
+
failed_reason: AUTH_ERROR_REASON,
|
|
1914
|
+
},
|
|
1915
|
+
});
|
|
1916
|
+
}
|
|
1917
|
+
warnings.push(`Task ${taskKey} failed due to auth/rate limit during review; continuing run. ${message}`);
|
|
1918
|
+
break;
|
|
1919
|
+
}
|
|
1920
|
+
this.recordFailure(progress, reviewOutcome, attemptIndex);
|
|
1921
|
+
this.recordRating(progress, reviewOutcome.ratingSummary);
|
|
1922
|
+
state.tasks[taskKey] = progress;
|
|
1923
|
+
await this.writeState(state);
|
|
1924
|
+
await this.deps.jobService.writeCheckpoint(jobId, {
|
|
1925
|
+
stage: `task:${taskKey}:review`,
|
|
1926
|
+
timestamp: new Date().toISOString(),
|
|
1927
|
+
details: { taskKey, attempt: attemptIndex, outcome: reviewOutcome },
|
|
1928
|
+
});
|
|
1929
|
+
if (reviewOutcome.error === ZERO_TOKEN_ERROR) {
|
|
1930
|
+
if (!resolvedRequest.dryRun) {
|
|
1931
|
+
await this.deps.workspaceRepo.updateTask(entry.task.id, { status: READY_TO_CODE_REVIEW });
|
|
1932
|
+
}
|
|
1933
|
+
const zeroTokenCount = this.countFailures(progress, "review", ZERO_TOKEN_ERROR);
|
|
1934
|
+
if (zeroTokenCount >= 2) {
|
|
1935
|
+
progress.status = "failed";
|
|
1936
|
+
progress.lastError = ZERO_TOKEN_ERROR;
|
|
1937
|
+
state.tasks[taskKey] = progress;
|
|
1938
|
+
await this.writeState(state);
|
|
1939
|
+
warnings.push(`Task ${taskKey} failed after repeated zero-token review runs.`);
|
|
1940
|
+
break;
|
|
1941
|
+
}
|
|
1942
|
+
warnings.push(`Retrying ${taskKey} after zero-token review run.`);
|
|
1943
|
+
state.tasks[taskKey] = progress;
|
|
1944
|
+
await this.writeState(state);
|
|
1945
|
+
await this.backoffZeroTokens(zeroTokenCount);
|
|
1946
|
+
continue;
|
|
1947
|
+
}
|
|
1948
|
+
const reviewGatewayFailure = reviewOutcome.status === "failed" &&
|
|
1949
|
+
normalizeFailureReason(reviewOutcome.error ?? reviewOutcome.decision) === GATEWAY_FAILED_REASON;
|
|
1950
|
+
if (reviewGatewayFailure) {
|
|
1951
|
+
progress.status = "failed";
|
|
1952
|
+
progress.lastError = GATEWAY_FAILED_REASON;
|
|
1953
|
+
state.tasks[taskKey] = progress;
|
|
1954
|
+
await this.writeState(state);
|
|
1955
|
+
if (!resolvedRequest.dryRun) {
|
|
1956
|
+
await this.deps.workspaceRepo.updateTask(entry.task.id, {
|
|
1957
|
+
status: "failed",
|
|
1958
|
+
metadata: {
|
|
1959
|
+
...entry.task.metadata,
|
|
1960
|
+
failed_reason: GATEWAY_FAILED_REASON,
|
|
1961
|
+
},
|
|
1962
|
+
});
|
|
1963
|
+
}
|
|
1964
|
+
warnings.push(`Task ${taskKey} failed due to gateway failure during review.`);
|
|
1965
|
+
break;
|
|
1966
|
+
}
|
|
1967
|
+
const retryReview = this.shouldRetryAfter(reviewOutcome);
|
|
1968
|
+
if (retryReview) {
|
|
1969
|
+
this.recordDecision(progress, "review", attemptIndex, "retry", "retry", reviewOutcome.decision ?? reviewOutcome.status);
|
|
1970
|
+
warnings.push(`Retrying ${taskKey} after review (${reviewOutcome.decision ?? reviewOutcome.status}).`);
|
|
1971
|
+
setDryRunStatus("in_progress");
|
|
1972
|
+
state.tasks[taskKey] = progress;
|
|
1973
|
+
await this.writeState(state);
|
|
1974
|
+
continue;
|
|
1975
|
+
}
|
|
1976
|
+
if (reviewOutcome.status === "failed" && reviewOutcome.guardrailRetryable === false) {
|
|
1977
|
+
const reason = normalizeFailureReason(reviewOutcome.guardrailReason ?? reviewOutcome.error ?? reviewOutcome.decision ?? "failed") ??
|
|
1978
|
+
reviewOutcome.guardrailReason ??
|
|
1979
|
+
reviewOutcome.error ??
|
|
1980
|
+
reviewOutcome.decision ??
|
|
1981
|
+
"failed";
|
|
1982
|
+
progress.status = "failed";
|
|
1983
|
+
progress.lastError = reason;
|
|
1984
|
+
this.recordDecision(progress, "review", attemptIndex, "terminal", "failed", reason);
|
|
1985
|
+
state.tasks[taskKey] = progress;
|
|
1986
|
+
await this.writeState(state);
|
|
1987
|
+
if (!resolvedRequest.dryRun) {
|
|
1988
|
+
await this.deps.workspaceRepo.updateTask(entry.task.id, {
|
|
1989
|
+
status: "failed",
|
|
1990
|
+
metadata: {
|
|
1991
|
+
...entry.task.metadata,
|
|
1992
|
+
failed_reason: reason,
|
|
1993
|
+
},
|
|
1994
|
+
});
|
|
1995
|
+
}
|
|
1996
|
+
warnings.push(`Task ${taskKey} failed with non-retryable review reason ${reason}.`);
|
|
1997
|
+
break;
|
|
1998
|
+
}
|
|
1999
|
+
if (resolvedRequest.dryRun) {
|
|
2000
|
+
if (reviewOutcome.decision === "changes_requested") {
|
|
2001
|
+
setDryRunStatus("changes_requested");
|
|
2002
|
+
}
|
|
2003
|
+
else {
|
|
2004
|
+
setDryRunStatus(reviewOutcome.status === "succeeded" ? "ready_to_qa" : "in_progress");
|
|
2005
|
+
}
|
|
2006
|
+
continue;
|
|
2007
|
+
}
|
|
2008
|
+
const statusAfterReview = await this.refreshTaskStatus(taskKey, warnings);
|
|
2009
|
+
if (statusAfterReview && statusAfterReview === "completed") {
|
|
2010
|
+
progress.status = "completed";
|
|
2011
|
+
state.tasks[taskKey] = progress;
|
|
2012
|
+
completedKeys.add(taskKey);
|
|
2013
|
+
completedThisCycle += 1;
|
|
2014
|
+
await this.writeState(state);
|
|
2015
|
+
break;
|
|
2016
|
+
}
|
|
2017
|
+
if (statusAfterReview && statusAfterReview === "failed") {
|
|
2018
|
+
progress.status = "failed";
|
|
2019
|
+
progress.lastError = progress.lastError ?? "failed";
|
|
2020
|
+
state.tasks[taskKey] = progress;
|
|
2021
|
+
await this.writeState(state);
|
|
2022
|
+
break;
|
|
2023
|
+
}
|
|
2024
|
+
if (statusAfterReview && statusAfterReview === "changes_requested") {
|
|
2025
|
+
continue;
|
|
2026
|
+
}
|
|
2027
|
+
if (statusAfterReview && !["ready_to_qa"].includes(statusAfterReview)) {
|
|
2028
|
+
warnings.push(`Task ${taskKey} status ${statusAfterReview} after review; retrying work step.`);
|
|
2029
|
+
continue;
|
|
2030
|
+
}
|
|
2031
|
+
continue;
|
|
2032
|
+
}
|
|
2033
|
+
if (statusNow === "ready_to_qa") {
|
|
2034
|
+
const attemptIndex = Math.max(progress.attempts, 1);
|
|
2035
|
+
progress.lastStep = "qa";
|
|
2036
|
+
state.tasks[taskKey] = progress;
|
|
2037
|
+
await this.writeState(state);
|
|
2038
|
+
const qaOutcome = await this.runStepWithTimeout("qa", jobId, taskKey, attemptIndex, maxAgentSeconds, (signal) => this.runQaStep(jobId, attemptIndex, taskKey, projectKey, ["ready_to_qa"], resolvedRequest, warnings, this.buildAgentOptions(progress, "qa", resolvedRequest), signal, this.handoffContextForProgress(progress), async (agent) => {
|
|
2039
|
+
progress.chosenAgents.qa = agent;
|
|
2040
|
+
state.tasks[taskKey] = progress;
|
|
2041
|
+
await this.writeState(state);
|
|
2042
|
+
}));
|
|
2043
|
+
progress.lastStep = "qa";
|
|
2044
|
+
progress.lastOutcome = qaOutcome.outcome;
|
|
2045
|
+
progress.lastError = qaOutcome.error;
|
|
2046
|
+
progress.chosenAgents.qa = qaOutcome.chosenAgent ?? progress.chosenAgents.qa;
|
|
2047
|
+
this.recordDecision(progress, "qa", attemptIndex, "result", qaOutcome.status, qaOutcome.outcome ?? qaOutcome.error ?? qaOutcome.notes);
|
|
2048
|
+
if (this.isAuthFailure(qaOutcome.error)) {
|
|
2049
|
+
const message = qaOutcome.error ?? AUTH_ERROR_REASON;
|
|
2050
|
+
qaOutcome.error = AUTH_ERROR_REASON;
|
|
2051
|
+
progress.lastError = AUTH_ERROR_REASON;
|
|
2052
|
+
this.recordFailure(progress, qaOutcome, attemptIndex);
|
|
2053
|
+
progress.status = "failed";
|
|
2054
|
+
state.tasks[taskKey] = progress;
|
|
2055
|
+
await this.writeState(state);
|
|
2056
|
+
if (!resolvedRequest.dryRun) {
|
|
2057
|
+
await this.deps.workspaceRepo.updateTask(entry.task.id, {
|
|
2058
|
+
status: "failed",
|
|
2059
|
+
metadata: {
|
|
2060
|
+
...entry.task.metadata,
|
|
2061
|
+
failed_reason: AUTH_ERROR_REASON,
|
|
2062
|
+
},
|
|
2063
|
+
});
|
|
2064
|
+
}
|
|
2065
|
+
warnings.push(`Task ${taskKey} failed due to auth/rate limit during QA; continuing run. ${message}`);
|
|
2066
|
+
break;
|
|
2067
|
+
}
|
|
2068
|
+
this.recordFailure(progress, qaOutcome, attemptIndex);
|
|
2069
|
+
this.recordRating(progress, qaOutcome.ratingSummary);
|
|
2070
|
+
state.tasks[taskKey] = progress;
|
|
2071
|
+
await this.writeState(state);
|
|
2072
|
+
await this.deps.jobService.writeCheckpoint(jobId, {
|
|
2073
|
+
stage: `task:${taskKey}:qa`,
|
|
2074
|
+
timestamp: new Date().toISOString(),
|
|
2075
|
+
details: { taskKey, attempt: attemptIndex, outcome: qaOutcome },
|
|
2076
|
+
});
|
|
2077
|
+
if (qaOutcome.error === ZERO_TOKEN_ERROR) {
|
|
2078
|
+
if (!resolvedRequest.dryRun) {
|
|
2079
|
+
await this.deps.workspaceRepo.updateTask(entry.task.id, { status: "ready_to_qa" });
|
|
2080
|
+
}
|
|
2081
|
+
const zeroTokenCount = this.countFailures(progress, "qa", ZERO_TOKEN_ERROR);
|
|
2082
|
+
if (zeroTokenCount >= 2) {
|
|
2083
|
+
progress.status = "failed";
|
|
2084
|
+
progress.lastError = ZERO_TOKEN_ERROR;
|
|
2085
|
+
state.tasks[taskKey] = progress;
|
|
2086
|
+
await this.writeState(state);
|
|
2087
|
+
warnings.push(`Task ${taskKey} failed after repeated zero-token QA runs.`);
|
|
2088
|
+
break;
|
|
2089
|
+
}
|
|
2090
|
+
warnings.push(`Retrying ${taskKey} after zero-token QA run.`);
|
|
2091
|
+
state.tasks[taskKey] = progress;
|
|
2092
|
+
await this.writeState(state);
|
|
2093
|
+
await this.backoffZeroTokens(zeroTokenCount);
|
|
2094
|
+
continue;
|
|
2095
|
+
}
|
|
2096
|
+
const qaGatewayFailure = qaOutcome.status === "failed" &&
|
|
2097
|
+
normalizeFailureReason(qaOutcome.error ?? qaOutcome.outcome) === GATEWAY_FAILED_REASON;
|
|
2098
|
+
if (qaGatewayFailure) {
|
|
2099
|
+
progress.status = "failed";
|
|
2100
|
+
progress.lastError = GATEWAY_FAILED_REASON;
|
|
2101
|
+
state.tasks[taskKey] = progress;
|
|
2102
|
+
await this.writeState(state);
|
|
2103
|
+
if (!resolvedRequest.dryRun) {
|
|
2104
|
+
await this.deps.workspaceRepo.updateTask(entry.task.id, {
|
|
2105
|
+
status: "failed",
|
|
2106
|
+
metadata: {
|
|
2107
|
+
...entry.task.metadata,
|
|
2108
|
+
failed_reason: GATEWAY_FAILED_REASON,
|
|
2109
|
+
},
|
|
2110
|
+
});
|
|
2111
|
+
}
|
|
2112
|
+
warnings.push(`Task ${taskKey} failed due to gateway failure during QA.`);
|
|
2113
|
+
break;
|
|
2114
|
+
}
|
|
2115
|
+
const retryQa = this.shouldRetryAfter(qaOutcome);
|
|
2116
|
+
if (retryQa) {
|
|
2117
|
+
progress.lastQaFailureSummary = this.buildQaFailureSummary(taskKey, qaOutcome);
|
|
2118
|
+
this.recordDecision(progress, "qa", attemptIndex, "retry", "retry", qaOutcome.outcome ?? qaOutcome.status);
|
|
2119
|
+
warnings.push(`Retrying ${taskKey} after QA (${qaOutcome.outcome ?? qaOutcome.status}).`);
|
|
2120
|
+
warnings.push(`Task ${taskKey} queued for gateway re-analysis after QA failure.`);
|
|
2121
|
+
setDryRunStatus("in_progress");
|
|
2122
|
+
state.tasks[taskKey] = progress;
|
|
2123
|
+
await this.writeState(state);
|
|
2124
|
+
continue;
|
|
2125
|
+
}
|
|
2126
|
+
if (qaOutcome.status === "failed" && qaOutcome.guardrailRetryable === false) {
|
|
2127
|
+
const reason = normalizeFailureReason(qaOutcome.guardrailReason ?? qaOutcome.error ?? qaOutcome.outcome ?? "failed") ??
|
|
2128
|
+
qaOutcome.guardrailReason ??
|
|
2129
|
+
qaOutcome.error ??
|
|
2130
|
+
qaOutcome.outcome ??
|
|
2131
|
+
"failed";
|
|
2132
|
+
progress.status = "failed";
|
|
2133
|
+
progress.lastError = reason;
|
|
2134
|
+
progress.lastQaFailureSummary = this.buildQaFailureSummary(taskKey, qaOutcome);
|
|
2135
|
+
this.recordDecision(progress, "qa", attemptIndex, "terminal", "failed", reason);
|
|
2136
|
+
state.tasks[taskKey] = progress;
|
|
2137
|
+
await this.writeState(state);
|
|
2138
|
+
if (!resolvedRequest.dryRun) {
|
|
2139
|
+
await this.deps.workspaceRepo.updateTask(entry.task.id, {
|
|
2140
|
+
status: "failed",
|
|
2141
|
+
metadata: {
|
|
2142
|
+
...entry.task.metadata,
|
|
2143
|
+
failed_reason: reason,
|
|
2144
|
+
},
|
|
2145
|
+
});
|
|
2146
|
+
}
|
|
2147
|
+
warnings.push(`Task ${taskKey} failed with non-retryable QA reason ${reason}.`);
|
|
2148
|
+
break;
|
|
2149
|
+
}
|
|
2150
|
+
if (resolvedRequest.dryRun) {
|
|
2151
|
+
setDryRunStatus(qaOutcome.status === "succeeded" ? "completed" : "in_progress");
|
|
2152
|
+
}
|
|
2153
|
+
else {
|
|
2154
|
+
const statusAfterQa = await this.refreshTaskStatus(taskKey, warnings);
|
|
2155
|
+
if (statusAfterQa && statusAfterQa === "failed") {
|
|
2156
|
+
progress.status = "failed";
|
|
2157
|
+
progress.lastError = progress.lastError ?? "failed";
|
|
2158
|
+
state.tasks[taskKey] = progress;
|
|
2159
|
+
await this.writeState(state);
|
|
2160
|
+
break;
|
|
2161
|
+
}
|
|
2162
|
+
if (statusAfterQa && statusAfterQa !== "completed") {
|
|
2163
|
+
warnings.push(`Task ${taskKey} status ${statusAfterQa} after QA; retrying work step.`);
|
|
2164
|
+
continue;
|
|
2165
|
+
}
|
|
2166
|
+
}
|
|
2167
|
+
progress.status = "completed";
|
|
2168
|
+
progress.lastQaFailureSummary = undefined;
|
|
2169
|
+
const touchedFiles = this.mergeGatewayFiles(qaOutcome).length > 0
|
|
2170
|
+
? this.mergeGatewayFiles(qaOutcome)
|
|
2171
|
+
: Array.isArray(entry.task.metadata?.last_review_changed_paths)
|
|
2172
|
+
? (entry.task.metadata.last_review_changed_paths || [])
|
|
2173
|
+
.filter((item) => typeof item === "string" && item.trim().length > 0)
|
|
2174
|
+
.map((item) => item.trim())
|
|
2175
|
+
: [];
|
|
2176
|
+
const planSummary = (qaOutcome.gatewayPlan ?? []).join(" | ") ||
|
|
2177
|
+
progress.stepOutcomes?.work ||
|
|
2178
|
+
"Work-review-QA loop completed";
|
|
2179
|
+
try {
|
|
2180
|
+
await this.appendGoldenExample({
|
|
2181
|
+
taskKey,
|
|
2182
|
+
title: entry.task.title,
|
|
2183
|
+
planSummary,
|
|
2184
|
+
touchedFiles,
|
|
2185
|
+
reviewNotes: progress.lastDecision,
|
|
2186
|
+
qaNotes: qaOutcome.notes ?? qaOutcome.outcome ?? qaOutcome.status,
|
|
2187
|
+
});
|
|
2188
|
+
}
|
|
2189
|
+
catch (error) {
|
|
2190
|
+
warnings.push(`Golden example capture failed for ${taskKey}: ${error instanceof Error ? error.message : String(error)}`);
|
|
2191
|
+
}
|
|
2192
|
+
state.tasks[taskKey] = progress;
|
|
2193
|
+
completedKeys.add(taskKey);
|
|
2194
|
+
await this.writeState(state);
|
|
2195
|
+
completedThisCycle += 1;
|
|
2196
|
+
taskCompleted = true;
|
|
2197
|
+
continue;
|
|
2198
|
+
}
|
|
2199
|
+
if (this.hasReachedMaxIterations(progress, maxIterations)) {
|
|
2200
|
+
progress.status = "failed";
|
|
2201
|
+
progress.lastError = "max_iterations_reached";
|
|
2202
|
+
state.tasks[taskKey] = progress;
|
|
2203
|
+
if (maxIterations !== undefined) {
|
|
2204
|
+
warnings.push(`Task ${taskKey} hit max iterations (${maxIterations}).`);
|
|
2205
|
+
}
|
|
2206
|
+
await this.writeState(state);
|
|
2207
|
+
break;
|
|
2208
|
+
}
|
|
2209
|
+
const attemptIndex = progress.attempts + 1;
|
|
2210
|
+
progress.attempts = attemptIndex;
|
|
2211
|
+
progress.lastError = undefined;
|
|
2212
|
+
progress.lastGuardrailRetryable = undefined;
|
|
2213
|
+
state.tasks[taskKey] = progress;
|
|
2214
|
+
await this.writeState(state);
|
|
2215
|
+
const workAgentOptions = this.buildAgentOptions(progress, "work", resolvedRequest);
|
|
2216
|
+
progress.lastStep = "work";
|
|
2217
|
+
state.tasks[taskKey] = progress;
|
|
2218
|
+
await this.writeState(state);
|
|
2219
|
+
const workOutcome = await this.runStepWithTimeout("work", jobId, taskKey, attemptIndex, maxAgentSeconds, (signal) => this.runWorkStep(jobId, attemptIndex, taskKey, projectKey, ["not_started", "in_progress", "changes_requested"], resolvedRequest, warnings, workAgentOptions, signal, this.handoffContextForProgress(progress), async (agent) => {
|
|
2220
|
+
progress.chosenAgents.work = agent;
|
|
2221
|
+
if (!progress.lastWorkAgentTier) {
|
|
2222
|
+
progress.lastWorkAgentTier = "unknown";
|
|
2223
|
+
}
|
|
2224
|
+
state.tasks[taskKey] = progress;
|
|
2225
|
+
await this.writeState(state);
|
|
2226
|
+
}));
|
|
2227
|
+
progress.lastStep = "work";
|
|
2228
|
+
progress.lastError = workOutcome.error;
|
|
2229
|
+
progress.chosenAgents.work = workOutcome.chosenAgent ?? progress.chosenAgents.work;
|
|
2230
|
+
if (workOutcome.tier) {
|
|
2231
|
+
progress.lastWorkAgentTier = workOutcome.tier;
|
|
2232
|
+
}
|
|
2233
|
+
else if (!progress.lastWorkAgentTier) {
|
|
2234
|
+
progress.lastWorkAgentTier = "unknown";
|
|
2235
|
+
}
|
|
2236
|
+
this.recordDecision(progress, "work", attemptIndex, "result", workOutcome.status, workOutcome.error ?? workOutcome.outcome ?? workOutcome.decision);
|
|
2237
|
+
if (this.isAuthFailure(workOutcome.error)) {
|
|
2238
|
+
const message = workOutcome.error ?? AUTH_ERROR_REASON;
|
|
2239
|
+
workOutcome.error = AUTH_ERROR_REASON;
|
|
2240
|
+
progress.lastError = AUTH_ERROR_REASON;
|
|
2241
|
+
this.recordFailure(progress, workOutcome, attemptIndex);
|
|
2242
|
+
progress.status = "failed";
|
|
2243
|
+
state.tasks[taskKey] = progress;
|
|
2244
|
+
await this.writeState(state);
|
|
2245
|
+
if (!resolvedRequest.dryRun) {
|
|
2246
|
+
await this.deps.workspaceRepo.updateTask(entry.task.id, {
|
|
2247
|
+
status: "failed",
|
|
2248
|
+
metadata: {
|
|
2249
|
+
...entry.task.metadata,
|
|
2250
|
+
failed_reason: AUTH_ERROR_REASON,
|
|
2251
|
+
},
|
|
2252
|
+
});
|
|
2253
|
+
}
|
|
2254
|
+
warnings.push(`Task ${taskKey} failed due to auth/rate limit during work; continuing run. ${message}`);
|
|
2255
|
+
break;
|
|
2256
|
+
}
|
|
2257
|
+
this.recordFailure(progress, workOutcome, attemptIndex);
|
|
2258
|
+
this.recordRating(progress, workOutcome.ratingSummary);
|
|
2259
|
+
state.tasks[taskKey] = progress;
|
|
2260
|
+
await this.writeState(state);
|
|
2261
|
+
await this.deps.jobService.writeCheckpoint(jobId, {
|
|
2262
|
+
stage: `task:${taskKey}:work`,
|
|
2263
|
+
timestamp: new Date().toISOString(),
|
|
2264
|
+
details: { taskKey, attempt: attemptIndex, outcome: workOutcome },
|
|
2265
|
+
});
|
|
2266
|
+
if (workOutcome.error === ZERO_TOKEN_ERROR) {
|
|
2267
|
+
if (!resolvedRequest.dryRun) {
|
|
2268
|
+
await this.deps.workspaceRepo.updateTask(entry.task.id, { status: "in_progress" });
|
|
2269
|
+
}
|
|
2270
|
+
const zeroTokenCount = this.countFailures(progress, "work", ZERO_TOKEN_ERROR);
|
|
2271
|
+
if (zeroTokenCount >= 2) {
|
|
2272
|
+
progress.status = "failed";
|
|
2273
|
+
progress.lastError = ZERO_TOKEN_ERROR;
|
|
2274
|
+
state.tasks[taskKey] = progress;
|
|
2275
|
+
await this.writeState(state);
|
|
2276
|
+
warnings.push(`Task ${taskKey} failed after repeated zero-token work runs.`);
|
|
2277
|
+
break;
|
|
2278
|
+
}
|
|
2279
|
+
warnings.push(`Retrying ${taskKey} after zero-token work run.`);
|
|
2280
|
+
state.tasks[taskKey] = progress;
|
|
2281
|
+
await this.writeState(state);
|
|
2282
|
+
await this.backoffZeroTokens(zeroTokenCount);
|
|
2283
|
+
continue;
|
|
2284
|
+
}
|
|
2285
|
+
if (workOutcome.error === "tests_failed") {
|
|
2286
|
+
const testsFailedCount = this.countFailures(progress, "work", "tests_failed");
|
|
2287
|
+
if (testsFailedCount >= 2) {
|
|
2288
|
+
progress.status = "failed";
|
|
2289
|
+
progress.lastError = "tests_failed";
|
|
2290
|
+
state.tasks[taskKey] = progress;
|
|
2291
|
+
await this.writeState(state);
|
|
2292
|
+
if (!resolvedRequest.dryRun) {
|
|
2293
|
+
await this.deps.workspaceRepo.updateTask(entry.task.id, {
|
|
2294
|
+
status: "failed",
|
|
2295
|
+
metadata: {
|
|
2296
|
+
...entry.task.metadata,
|
|
2297
|
+
failed_reason: "tests_failed",
|
|
2298
|
+
},
|
|
2299
|
+
});
|
|
2300
|
+
}
|
|
2301
|
+
warnings.push(`Task ${taskKey} failed after repeated tests_failed.`);
|
|
2302
|
+
break;
|
|
2303
|
+
}
|
|
2304
|
+
warnings.push(`Retrying ${taskKey} after tests_failed with stronger agent.`);
|
|
2305
|
+
state.tasks[taskKey] = progress;
|
|
2306
|
+
await this.writeState(state);
|
|
2307
|
+
continue;
|
|
2308
|
+
}
|
|
2309
|
+
const workGatewayFailure = workOutcome.status === "failed" &&
|
|
2310
|
+
normalizeFailureReason(workOutcome.error ?? workOutcome.status) === GATEWAY_FAILED_REASON;
|
|
2311
|
+
if (workGatewayFailure) {
|
|
2312
|
+
progress.status = "failed";
|
|
2313
|
+
progress.lastError = GATEWAY_FAILED_REASON;
|
|
2314
|
+
state.tasks[taskKey] = progress;
|
|
2315
|
+
await this.writeState(state);
|
|
2316
|
+
if (!resolvedRequest.dryRun) {
|
|
2317
|
+
await this.deps.workspaceRepo.updateTask(entry.task.id, {
|
|
2318
|
+
status: "failed",
|
|
2319
|
+
metadata: {
|
|
2320
|
+
...entry.task.metadata,
|
|
2321
|
+
failed_reason: GATEWAY_FAILED_REASON,
|
|
2322
|
+
},
|
|
2323
|
+
});
|
|
2324
|
+
}
|
|
2325
|
+
warnings.push(`Task ${taskKey} failed due to gateway failure during work.`);
|
|
2326
|
+
break;
|
|
2327
|
+
}
|
|
2328
|
+
if (workOutcome.status === "skipped") {
|
|
2329
|
+
progress.status = "skipped";
|
|
2330
|
+
progress.lastError = workOutcome.error ?? "skipped";
|
|
2331
|
+
this.recordDecision(progress, "work", attemptIndex, "terminal", "skipped", progress.lastError);
|
|
2332
|
+
state.tasks[taskKey] = progress;
|
|
2333
|
+
await this.writeState(state);
|
|
2334
|
+
break;
|
|
2335
|
+
}
|
|
2336
|
+
const retryWork = this.shouldRetryAfter(workOutcome);
|
|
2337
|
+
if (workOutcome.status !== "succeeded" && retryWork) {
|
|
2338
|
+
this.recordDecision(progress, "work", attemptIndex, "retry", "retry", workOutcome.error ?? workOutcome.status);
|
|
2339
|
+
warnings.push(`Retrying ${taskKey} after work step (${workOutcome.status}).`);
|
|
2340
|
+
state.tasks[taskKey] = progress;
|
|
2341
|
+
await this.writeState(state);
|
|
2342
|
+
continue;
|
|
2343
|
+
}
|
|
2344
|
+
if (workOutcome.status === "failed" && workOutcome.guardrailRetryable === false) {
|
|
2345
|
+
const reason = normalizeFailureReason(workOutcome.guardrailReason ?? workOutcome.error ?? workOutcome.status) ??
|
|
2346
|
+
workOutcome.guardrailReason ??
|
|
2347
|
+
workOutcome.error ??
|
|
2348
|
+
"failed";
|
|
2349
|
+
progress.status = "failed";
|
|
2350
|
+
progress.lastError = reason;
|
|
2351
|
+
this.recordDecision(progress, "work", attemptIndex, "terminal", "failed", reason);
|
|
2352
|
+
state.tasks[taskKey] = progress;
|
|
2353
|
+
await this.writeState(state);
|
|
2354
|
+
if (!resolvedRequest.dryRun) {
|
|
2355
|
+
await this.deps.workspaceRepo.updateTask(entry.task.id, {
|
|
2356
|
+
status: "failed",
|
|
2357
|
+
metadata: {
|
|
2358
|
+
...entry.task.metadata,
|
|
2359
|
+
failed_reason: reason,
|
|
2360
|
+
},
|
|
2361
|
+
});
|
|
2362
|
+
}
|
|
2363
|
+
warnings.push(`Task ${taskKey} failed with non-retryable work reason ${reason}.`);
|
|
2364
|
+
break;
|
|
2365
|
+
}
|
|
2366
|
+
if (resolvedRequest.dryRun) {
|
|
2367
|
+
setDryRunStatus(READY_TO_CODE_REVIEW);
|
|
2368
|
+
continue;
|
|
2369
|
+
}
|
|
2370
|
+
const statusAfterWork = await this.refreshTaskStatus(taskKey, warnings);
|
|
2371
|
+
if (statusAfterWork &&
|
|
2372
|
+
(isReadyToReviewStatus(statusAfterWork) || ["ready_to_qa", "completed"].includes(statusAfterWork))) {
|
|
2373
|
+
currentStatus = statusAfterWork;
|
|
2374
|
+
continue;
|
|
2375
|
+
}
|
|
2376
|
+
if (statusAfterWork && statusAfterWork === "failed") {
|
|
2377
|
+
progress.status = "failed";
|
|
2378
|
+
progress.lastError = "failed";
|
|
2379
|
+
state.tasks[taskKey] = progress;
|
|
2380
|
+
await this.writeState(state);
|
|
2381
|
+
break;
|
|
2382
|
+
}
|
|
2383
|
+
if (statusAfterWork && statusAfterWork === "blocked") {
|
|
2384
|
+
progress.status = "failed";
|
|
2385
|
+
progress.lastError = "legacy_blocked";
|
|
2386
|
+
state.tasks[taskKey] = progress;
|
|
2387
|
+
await this.writeState(state);
|
|
2388
|
+
warnings.push(`Task ${taskKey} had legacy blocked status after work; marked failed.`);
|
|
2389
|
+
break;
|
|
2390
|
+
}
|
|
2391
|
+
if (statusAfterWork) {
|
|
2392
|
+
warnings.push(`Task ${taskKey} status ${statusAfterWork} after work; retrying work step.`);
|
|
2393
|
+
progress.lastError = "work_status_not_ready";
|
|
2394
|
+
const statusFailure = {
|
|
2395
|
+
step: "work",
|
|
2396
|
+
status: "failed",
|
|
2397
|
+
error: "work_status_not_ready",
|
|
2398
|
+
chosenAgent: progress.chosenAgents.work,
|
|
2399
|
+
};
|
|
2400
|
+
this.recordFailure(progress, statusFailure, attemptIndex);
|
|
2401
|
+
state.tasks[taskKey] = progress;
|
|
2402
|
+
await this.writeState(state);
|
|
2403
|
+
continue;
|
|
2404
|
+
}
|
|
2405
|
+
warnings.push(`Task ${taskKey} status missing after work; retrying work step.`);
|
|
2406
|
+
continue;
|
|
2407
|
+
}
|
|
2408
|
+
if (attempted && !abortRemainingReason) {
|
|
2409
|
+
const finalProgress = currentTaskKey ? state.tasks[currentTaskKey] : undefined;
|
|
2410
|
+
if (finalProgress) {
|
|
2411
|
+
if (finalProgress.status === "completed") {
|
|
2412
|
+
activeTaskKey = null;
|
|
2413
|
+
}
|
|
2414
|
+
else {
|
|
2415
|
+
activeTaskKey = currentTaskKey ?? activeTaskKey;
|
|
2416
|
+
holdAfterTask = true;
|
|
2417
|
+
}
|
|
2418
|
+
}
|
|
2419
|
+
if (typeof taskLimit === "number" && taskLimit > 0 && completedKeys.size >= taskLimit) {
|
|
2420
|
+
warnings.push(`Completed task limit ${taskLimit} reached; stopping run.`);
|
|
2421
|
+
abortRemainingReason = "limit_reached";
|
|
2422
|
+
}
|
|
2423
|
+
}
|
|
2424
|
+
}
|
|
2425
|
+
finally {
|
|
2426
|
+
if (attempted) {
|
|
2427
|
+
processedThisCycle += 1;
|
|
2428
|
+
await this.deps.jobService.updateJobStatus(jobId, "running", {
|
|
2429
|
+
processedItems: processedThisCycle,
|
|
2430
|
+
});
|
|
2431
|
+
}
|
|
2432
|
+
}
|
|
2433
|
+
if (abortRemainingReason || holdAfterTask)
|
|
2434
|
+
break;
|
|
2435
|
+
}
|
|
2436
|
+
if (abortRemainingReason)
|
|
2437
|
+
break;
|
|
2438
|
+
cycle += 1;
|
|
2439
|
+
state.cycle = cycle;
|
|
2440
|
+
await this.writeState(state);
|
|
2441
|
+
if (attemptedThisCycle === 0) {
|
|
2442
|
+
const hasRemaining = Object.values(state.tasks).some((task) => {
|
|
2443
|
+
if (task.status === "completed" || task.status === "skipped")
|
|
2444
|
+
return false;
|
|
2445
|
+
if (maxIterations !== undefined && task.attempts >= maxIterations)
|
|
2446
|
+
return false;
|
|
2447
|
+
return true;
|
|
2448
|
+
});
|
|
2449
|
+
if (hasRemaining) {
|
|
2450
|
+
warnings.push("No tasks attempted in this cycle; tasks remain, continuing.");
|
|
2451
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
2452
|
+
continue;
|
|
2453
|
+
}
|
|
2454
|
+
warnings.push("No tasks attempted in this cycle; stopping.");
|
|
2455
|
+
break;
|
|
2456
|
+
}
|
|
2457
|
+
}
|
|
2458
|
+
const summaries = this.toSummary(state);
|
|
2459
|
+
const failed = summaries.filter((t) => t.status === "failed").map((t) => t.taskKey);
|
|
2460
|
+
const skipped = summaries.filter((t) => t.status === "skipped").map((t) => t.taskKey);
|
|
2461
|
+
const pending = summaries.filter((t) => t.status === "pending").map((t) => t.taskKey);
|
|
2462
|
+
const failureCount = failed.length + skipped.length + pending.length;
|
|
2463
|
+
const jobSnapshot = await this.deps.jobService.getJob(jobId);
|
|
2464
|
+
const jobStateNow = resolveJobState(jobSnapshot);
|
|
2465
|
+
const cancelled = jobStateNow === "cancelled" || abortRemainingReason === "cancelled";
|
|
2466
|
+
const endState = cancelled ? "cancelled" : failureCount === 0 ? "completed" : "partial";
|
|
2467
|
+
const errorSummary = cancelled ? "Job cancelled by user" : failureCount ? `${failureCount} task(s) not fully completed` : undefined;
|
|
2468
|
+
await this.deps.jobService.updateJobStatus(jobId, endState, { errorSummary });
|
|
2469
|
+
const commandStatus = endState === "completed" ? "succeeded" : endState === "cancelled" ? "cancelled" : "failed";
|
|
2470
|
+
await this.deps.jobService.finishCommandRun(commandRun.id, commandStatus, errorSummary);
|
|
2471
|
+
await this.deps.jobService.writeCheckpoint(jobId, {
|
|
2472
|
+
stage: "completed",
|
|
2473
|
+
timestamp: new Date().toISOString(),
|
|
2474
|
+
details: { cycle, tasks: summaries },
|
|
2475
|
+
});
|
|
2476
|
+
return {
|
|
2477
|
+
jobId,
|
|
2478
|
+
commandRunId: commandRun.id,
|
|
2479
|
+
tasks: summaries,
|
|
2480
|
+
warnings,
|
|
2481
|
+
failed,
|
|
2482
|
+
skipped,
|
|
2483
|
+
};
|
|
2484
|
+
}
|
|
2485
|
+
catch (error) {
|
|
2486
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2487
|
+
await this.deps.jobService.updateJobStatus(jobId, "failed", { errorSummary: message });
|
|
2488
|
+
await this.deps.jobService.finishCommandRun(commandRun.id, "failed", message);
|
|
2489
|
+
throw error;
|
|
2490
|
+
}
|
|
2491
|
+
}
|
|
2492
|
+
}
|