From f4e06a6aa08cd64865c8e30086303c2ebaaf9958 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sat, 16 May 2026 01:57:03 -0400 Subject: [PATCH 1/4] Fix mission workflow phase orchestration --- apps/ade-cli/src/adeRpcServer.test.ts | 71 ++++ apps/ade-cli/src/adeRpcServer.ts | 69 +++- apps/ade-cli/src/bootstrap.ts | 18 +- apps/ade-cli/src/cli.test.ts | 17 + apps/ade-cli/src/cli.ts | 37 ++- apps/ade-cli/src/stdioRpcDaemon.test.ts | 54 ++++ .../src/main/services/adeActions/registry.ts | 42 ++- .../missions/missionPreflightService.ts | 14 +- .../main/services/missions/missionService.ts | 13 +- .../services/missions/phaseEngine.test.ts | 35 ++ .../src/main/services/missions/phaseEngine.ts | 32 ++ .../aiOrchestratorService.test.ts | 126 +++++++- .../orchestrator/aiOrchestratorService.ts | 125 ++++++- .../services/orchestrator/coordinatorAgent.ts | 26 +- .../orchestrator/coordinatorEventFormatter.ts | 4 +- .../orchestrator/coordinatorTools.test.ts | 305 +++++++++++++++++- .../services/orchestrator/coordinatorTools.ts | 90 +++++- .../orchestrator/executionPolicy.test.ts | 124 ++++++- .../services/orchestrator/executionPolicy.ts | 133 +++++++- .../orchestrator/orchestratorAdapters.test.ts | 81 +++++ .../orchestrator/orchestratorService.test.ts | 180 +++++++++++ .../orchestrator/orchestratorService.ts | 197 +++++++++-- .../providerOrchestratorAdapter.ts | 20 +- docs/features/missions/README.md | 2 +- 24 files changed, 1703 insertions(+), 112 deletions(-) diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index 794401464..0d17a3939 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -3494,6 +3494,77 @@ describe("adeRpcServer", () => { }); }); + it("normalizes legacy string test summaries for report_result", async () => { + await withEnv({ ADE_RUN_ID: "run-1" }, async () => { + const fixture = createRuntime(); + fixture.runtime.orchestratorService.getRunGraph = vi.fn(() => ({ + run: { id: "run-1", missionId: "mission-1", status: "running", metadata: {} }, + steps: [ + { + id: "step-parent", + runId: "run-1", + missionStepId: null, + stepKey: "parent-worker", + stepIndex: 0, + title: "Parent Worker", + laneId: "lane-1", + status: "running", + joinPolicy: "all_success", + quorumCount: null, + dependencyStepIds: [], + retryLimit: 1, + retryCount: 0, + lastAttemptId: "attempt-parent", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + startedAt: new Date().toISOString(), + completedAt: null, + metadata: {} + } + ], + attempts: [ + { id: "attempt-parent", stepId: "step-parent", status: "running", createdAt: new Date().toISOString() } + ], + claims: [], + contextSnapshots: [], + handoffs: [], + timeline: [], + runtimeEvents: [], + completionEvaluation: null + })); + + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + await initialize(handler, { + callerId: "attempt-parent", + role: "agent", + missionId: "mission-1", + runId: "run-from-identity", + stepId: "step-parent", + attemptId: "attempt-parent" + }); + + const response = await callTool(handler, "report_result", { + outcome: "succeeded", + summary: "Finished with validation.", + artifacts: [], + filesChanged: [], + testsRun: [ + "npm run typecheck (passed)", + "npm test (passed: 2 files, 3 tests)", + "ADE_PROJECT_ROOT=/tmp/app npm run build (passed)" + ] + }); + + expect(response?.isError).toBeUndefined(); + expect(response.structuredContent.report.testsRun).toMatchObject({ + passed: 3, + failed: 0, + skipped: 0, + raw: expect.stringContaining("npm test (passed: 2 files, 3 tests)") + }); + }); + }); + it("uses trusted env run context for shared-fact writes instead of initialize payload runId", async () => { await withEnv({ ADE_RUN_ID: "run-from-env" }, async () => { const fixture = createRuntime(); diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index 858f60615..033dbb71e 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -3990,22 +3990,73 @@ function normalizeWorkerOutcome(raw: unknown): "succeeded" | "failed" | "partial return "partial"; } -function summarizeLegacyTestsRun(entries: unknown): Record | null { +function parseLegacyTestCount(text: string, labels: string[]): number | null { + const escapedLabels = labels.map((label) => label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|"); + const labelFirst = new RegExp(`(?:^|[^\\w])(?:${escapedLabels})\\s*:?\\s*(\\d+)\\b`, "i"); + const valueFirst = new RegExp(`\\b(\\d+)\\s+(?:${escapedLabels})\\b`, "i"); + const match = labelFirst.exec(text) ?? valueFirst.exec(text); + if (!match?.[1]) return null; + const value = Number(match[1]); + return Number.isFinite(value) ? value : null; +} + +function summarizeLegacyTestsRun(entries: unknown): Record | null { if (!Array.isArray(entries)) return null; - let passed = 0; - let failed = 0; - let skipped = 0; + const statusCounts = { passed: 0, failed: 0, skipped: 0 }; + const counted = { passed: 0, failed: 0, skipped: 0 }; + const rawEntries: string[] = []; + const commands: string[] = []; + let sawNumericCount = false; for (const entry of entries) { - const result = asOptionalTrimmedString(safeObject(entry).result)?.toLowerCase() ?? ""; + const record = safeObject(entry); + const stringEntry = typeof entry === "string" ? entry.trim() : ""; + const command = asOptionalTrimmedString(record.command) ?? asOptionalTrimmedString(record.name) ?? ""; + const result = asOptionalTrimmedString(record.result)?.toLowerCase() ?? ""; + const text = [stringEntry, command, result, asOptionalTrimmedString(record.raw) ?? ""].filter((part) => part.length > 0).join(" "); + if (text.trim().length > 0) rawEntries.push(text.trim()); + if (command) commands.push(command); + + const failedCount = parseLegacyTestCount(text, ["failed", "failures", "failure", "fail"]); + const skippedCount = parseLegacyTestCount(text, ["skipped", "skip"]); + const passedFromTests = + /\b(?:pass|passed|passing|success|successful)\b/i.test(text) + ? /\b(\d+)\s+tests?\b/i.exec(text)?.[1] + : null; + const passedCount = passedFromTests && Number.isFinite(Number(passedFromTests)) + ? Number(passedFromTests) + : parseLegacyTestCount(text, ["passed", "passing", "pass"]); + if (passedCount != null || failedCount != null || skippedCount != null) { + sawNumericCount = true; + counted.passed += passedCount ?? 0; + counted.failed += failedCount ?? 0; + counted.skipped += skippedCount ?? 0; + continue; + } + if (result === "pass" || result === "passed" || result === "success" || result === "succeeded") { - passed += 1; + statusCounts.passed += 1; + } else if (!result && /\b(?:pass|passed|success|succeeded)\b/i.test(text) && !/\b(?:fail|failed|error)\b/i.test(text)) { + statusCounts.passed += 1; } else if (result === "fail" || result === "failed" || result === "error") { - failed += 1; + statusCounts.failed += 1; + } else if (!result && /\b(?:fail|failed|failure|error)\b/i.test(text)) { + statusCounts.failed += 1; } else { - skipped += 1; + statusCounts.skipped += 1; } } - return { passed, failed, skipped }; + const summary = sawNumericCount + ? { + passed: counted.passed, + failed: counted.failed + statusCounts.failed, + skipped: counted.skipped + statusCounts.skipped, + } + : statusCounts; + return { + ...summary, + ...(commands.length > 0 ? { command: commands.join("; ") } : {}), + ...(rawEntries.length > 0 ? { raw: rawEntries.join("\n") } : {}), + }; } function normalizeCoordinatorWorkerToolArgs(args: { diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index 0b2a5e0b7..bdd904f1b 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -19,7 +19,7 @@ import { createConflictService } from "../../desktop/src/main/services/conflicts import { createGitOperationsService } from "../../desktop/src/main/services/git/gitOperationsService"; import { createDiffService } from "../../desktop/src/main/services/diffs/diffService"; import { createMissionService } from "../../desktop/src/main/services/missions/missionService"; -import type { createMissionPreflightService } from "../../desktop/src/main/services/missions/missionPreflightService"; +import { createMissionPreflightService } from "../../desktop/src/main/services/missions/missionPreflightService"; import { createPtyService } from "../../desktop/src/main/services/pty/ptyService"; import { createTestService } from "../../desktop/src/main/services/tests/testService"; import { createKeybindingsService } from "../../desktop/src/main/services/keybindings/keybindingsService"; @@ -284,14 +284,14 @@ function ensureAdeCliShim(entryPath: string): { dir: string; path: string } | nu fs.mkdirSync(shimDir, { recursive: true }); if (process.platform === "win32") { const body = isJavaScriptCliEntry(entryPath) - ? `@echo off\r\n"${process.execPath}" "${entryPath}" %*\r\n` + ? `@echo off\r\nset ELECTRON_RUN_AS_NODE=1\r\n"${process.execPath}" "${entryPath}" %*\r\n` : `@echo off\r\n"${entryPath}" %*\r\n`; if (!fs.existsSync(shimPath) || fs.readFileSync(shimPath, "utf8") !== body) { fs.writeFileSync(shimPath, body, "utf8"); } } else { const body = isJavaScriptCliEntry(entryPath) - ? `#!/bin/sh\nexec ${JSON.stringify(process.execPath)} ${JSON.stringify(entryPath)} "$@"\n` + ? `#!/bin/sh\nELECTRON_RUN_AS_NODE=1 exec ${JSON.stringify(process.execPath)} ${JSON.stringify(entryPath)} "$@"\n` : `#!/bin/sh\nexec ${JSON.stringify(entryPath)} "$@"\n`; if (!fs.existsSync(shimPath) || fs.readFileSync(shimPath, "utf8") !== body) { fs.writeFileSync(shimPath, body, "utf8"); @@ -770,6 +770,17 @@ export async function createAdeRuntime(args: { orchestratorService, logger, }); + const missionPreflightService = createMissionPreflightService({ + logger, + projectRoot, + missionService, + laneService, + aiIntegrationService, + projectConfigService, + missionBudgetService, + humanWorkDigestService: null, + computerUseArtifactBrokerService, + }); const iosSimulatorService = chatOnlyRuntime ? null : createIosSimulatorService({ @@ -1128,6 +1139,7 @@ export async function createAdeRuntime(args: { gitService, diffService, missionService, + missionPreflightService, missionBudgetService, syncService, syncHostService: syncService?.getHostService() ?? null, diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index dcae04efc..1d70dad3b 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -845,6 +845,23 @@ describe("ADE CLI", () => { priority: "high", }, }); + + const status = buildCliPlan([ + "report_status", + "--text", + "Running npm run typecheck", + ]); + expect(status.kind).toBe("execute"); + if (status.kind !== "execute") return; + expect(status.steps[0]?.params).toEqual({ + name: "report_status", + arguments: { + status: "running", + summary: "Running npm run typecheck", + nextAction: "Running npm run typecheck", + details: "Running npm run typecheck", + }, + }); }); it("rejects invalid JSON action shapes before execution", () => { diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 8c6c620c9..a07f4559e 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -177,6 +177,7 @@ const VERSION = BUNDLED_VERSION && BUNDLED_VERSION !== "0.0.0" ? BUNDLED_VERSION : ENV_VERSION || BUNDLED_VERSION || "0.0.0"; +const PLACEHOLDER_VERSION = "0.0.0"; const PROTOCOL_VERSION = "2025-06-18"; const SOURCE_FALLBACK_ENV = "ADE_CLI_SOURCE_FALLBACK_ACTIVE"; const CLI_ENTRY_PATH = @@ -191,6 +192,9 @@ const WORKER_MISSION_TOOL_CLI_NAMES = new Set([ "get_pending_messages", "stream_events", "message_worker", + "report_status", + "report_result", + "report_validation", ]); function resolveCliPackageRoot(entryPath: string): string { @@ -2263,6 +2267,25 @@ function buildWorkerMissionToolPlan(name: string, args: string[]): CliPlan { priority: readValue(args, ["--priority"]) ?? "normal", }); } + if (name === "report_status") { + const message = + readValue(args, ["--text", "--message", "--summary"]) ?? + args + .filter((entry) => entry !== "--" && !entry.startsWith("-")) + .join(" ") + .trim(); + return collectGenericObjectArgs(args, { + status: readValue(args, ["--status"]) ?? "running", + summary: message || undefined, + nextAction: message || undefined, + details: message || undefined, + }); + } + if (name === "report_result" || name === "report_validation") { + return collectGenericObjectArgs(args, { + summary: readValue(args, ["--text", "--message", "--summary"]), + }); + } return collectGenericObjectArgs(args); })(); return { @@ -10224,6 +10247,14 @@ function readRuntimeInfoVersion(value: unknown): string | null { return asString(value.runtimeInfo.version); } +function shouldReplaceMachineRuntimeVersion(runtimeVersion: string | null): boolean { + return Boolean( + runtimeVersion && + runtimeVersion !== VERSION && + VERSION !== PLACEHOLDER_VERSION, + ); +} + async function initializeMachineRuntimeDaemon( client: SocketJsonRpcClient, options: GlobalOptions, @@ -10303,7 +10334,7 @@ async function connectMachineRuntimeDaemon( client, options, ); - if (runtimeVersion && runtimeVersion !== VERSION) { + if (shouldReplaceMachineRuntimeVersion(runtimeVersion)) { if (!allowSpawn) { client.close(); throw new Error( @@ -10326,7 +10357,7 @@ async function connectMachineRuntimeDaemon( restarted, options, ); - if (restartedVersion && restartedVersion !== VERSION) { + if (shouldReplaceMachineRuntimeVersion(restartedVersion)) { await shutdownMachineRuntimeDaemon(restarted); throw new Error( `ADE runtime daemon version ${restartedVersion} does not match CLI version ${VERSION}.`, @@ -10349,7 +10380,7 @@ async function connectMachineRuntimeDaemon( client, options, ); - if (runtimeVersion && runtimeVersion !== VERSION) { + if (shouldReplaceMachineRuntimeVersion(runtimeVersion)) { await shutdownMachineRuntimeDaemon(client); throw new Error( `ADE runtime daemon version ${runtimeVersion} does not match CLI version ${VERSION}.`, diff --git a/apps/ade-cli/src/stdioRpcDaemon.test.ts b/apps/ade-cli/src/stdioRpcDaemon.test.ts index f7acded85..130df73ef 100644 --- a/apps/ade-cli/src/stdioRpcDaemon.test.ts +++ b/apps/ade-cli/src/stdioRpcDaemon.test.ts @@ -297,4 +297,58 @@ describe("ade rpc --stdio daemon bridge", () => { if (!oldDaemon.killed) oldDaemon.kill(); } }, 45_000); + + itUnix("does not replace a real daemon when the bridging CLI has only the placeholder version", async () => { + const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + const cliPath = path.join(packageRoot, "src", "cli.ts"); + const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-stdio-rpc-placeholder-version-")); + const socketPath = path.join(adeHome, "sock", "ade.sock"); + const baseEnv = { + ...process.env, + ADE_HOME: adeHome, + ADE_RUNTIME_SOCKET_PATH: socketPath, + NODE_OPTIONS: withTsxNodeOptions(process.env.NODE_OPTIONS), + }; + const realDaemon = startServeProcess({ + cliPath, + cwd: packageRoot, + env: { + ...baseEnv, + ADE_CLI_VERSION: "2.0.0", + }, + socketPath, + }); + + let proxy: StdioRpcProcess | null = null; + try { + await waitForSocket(socketPath); + + const placeholderEnv: NodeJS.ProcessEnv = { ...baseEnv }; + delete placeholderEnv.ADE_CLI_VERSION; + proxy = StdioRpcProcess.start({ + cliPath, + cwd: packageRoot, + env: placeholderEnv, + }); + const initialize = await proxy.request("ade/initialize", { + protocolVersion: "2025-06-18", + clientName: "stdio-daemon-placeholder-version-test", + identity: { role: "external", callerId: "stdio-daemon-placeholder-version-test" }, + }); + + expect(initialize).toMatchObject({ + runtimeInfo: { + version: "2.0.0", + multiProject: true, + }, + }); + + await expect(proxy.request("shutdown")).resolves.toEqual({}); + proxy.closeInput(); + await expect(proxy.waitForExit()).resolves.toMatchObject({ code: 0, signal: null }); + } finally { + proxy?.kill(); + if (!realDaemon.killed) realDaemon.kill(); + } + }, 45_000); }); diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index 1713762bc..7a6191e22 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -1945,6 +1945,34 @@ function buildMissionDomainService(runtime: AdeRuntime): OpaqueService | null { }; } +async function waitForMissionCloseoutAfterFinalize( + runtime: AdeRuntime, + runId: string, + result: unknown, +): Promise { + const finalized = asActionRecord(result).finalized === true; + const finalStatus = String(asActionRecord(result).finalStatus ?? ""); + if (!finalized || finalStatus !== "succeeded" || !runtime.orchestratorService || !runtime.missionService) return; + + let missionId = ""; + try { + const graph = runtime.orchestratorService.getRunGraph({ runId, timelineLimit: 0 }); + missionId = String(graph.run.missionId ?? "").trim(); + } catch { + return; + } + if (!missionId) return; + + const terminalMissionStatuses = new Set(["completed", "failed", "canceled", "intervention_required"]); + const started = Date.now(); + while (Date.now() - started < 10_000) { + const mission = await Promise.resolve(runtime.missionService.get(missionId)); + const status = typeof mission?.status === "string" ? mission.status : ""; + if (terminalMissionStatuses.has(status)) return; + await new Promise((resolve) => setTimeout(resolve, 100)); + } +} + function buildOrchestratorCoreDomainService(runtime: AdeRuntime): OpaqueService | null { const service = runtime.orchestratorService; if (!service) return null; @@ -1964,8 +1992,13 @@ function buildOrchestratorCoreDomainService(runtime: AdeRuntime): OpaqueService compactRunForTransport(service.pauseRun(args)), resumeRun: (args: Parameters[0]) => compactRunForTransport(service.resumeRun(args)), - finalizeRun: (args: Parameters[0]) => - service.finalizeRun(args), + finalizeRun: async (args: Parameters[0]) => { + const result = runtime.aiOrchestratorService?.finalizeRun + ? runtime.aiOrchestratorService.finalizeRun(args as never) + : service.finalizeRun(args); + await waitForMissionCloseoutAfterFinalize(runtime, args.runId, result); + return result; + }, }; } @@ -1982,6 +2015,11 @@ function buildAiOrchestratorDomainService(runtime: AdeRuntime): OpaqueService | service.getThreadMessages(args).map(compactChatMessageForTransport), sendThreadMessage: async (args: Parameters[0]) => compactChatMessageForTransport(await service.sendThreadMessage(args)), + finalizeRun: async (args: Parameters[0]) => { + const result = service.finalizeRun(args); + await waitForMissionCloseoutAfterFinalize(runtime, args.runId, result); + return result; + }, cancelRunGracefully: async (args: Parameters[0]) => compactRunForTransport(await service.cancelRunGracefully(args)), resumeRun: async (args: Parameters[0]) => diff --git a/apps/desktop/src/main/services/missions/missionPreflightService.ts b/apps/desktop/src/main/services/missions/missionPreflightService.ts index 51560a458..2784457d6 100644 --- a/apps/desktop/src/main/services/missions/missionPreflightService.ts +++ b/apps/desktop/src/main/services/missions/missionPreflightService.ts @@ -11,7 +11,7 @@ import type { PhaseCard, PhaseProfile, } from "../../../shared/types"; -import { createBuiltInPhaseCards, validatePhaseSequence } from "./phaseEngine"; +import { createBuiltInPhaseCards, ensurePlanningPhase, validatePhaseSequence } from "./phaseEngine"; import { getModelById, resolveModelAlias } from "../../../shared/modelRegistry"; import type { MissionBudgetService } from "../orchestrator/missionBudgetService"; import { mergeMissionPermissionConfig, normalizeMissionPermissions } from "../orchestrator/permissionMapping"; @@ -102,11 +102,13 @@ function resolveSelectedPhases(args: { : args.profiles.find((profile) => profile.isDefault) ?? args.profiles[0] ?? null; const hasOverride = Array.isArray(args.launch.phaseOverride) && args.launch.phaseOverride.length > 0; const phases = normalizePhaseCards( - hasOverride - ? args.launch.phaseOverride ?? [] - : selectedProfile?.phases?.length - ? selectedProfile.phases - : createBuiltInPhaseCards(), + ensurePlanningPhase( + hasOverride + ? args.launch.phaseOverride ?? [] + : selectedProfile?.phases?.length + ? selectedProfile.phases + : createBuiltInPhaseCards() + ) ); return { profile: selectedProfile, phases }; } diff --git a/apps/desktop/src/main/services/missions/missionService.ts b/apps/desktop/src/main/services/missions/missionService.ts index b63f93d4c..3f74b340c 100644 --- a/apps/desktop/src/main/services/missions/missionService.ts +++ b/apps/desktop/src/main/services/missions/missionService.ts @@ -75,6 +75,7 @@ import { applyPhaseCardsToPlanSteps, createBuiltInPhaseCards, createBuiltInPhaseProfiles, + ensurePlanningPhase, normalizeProfileInput, validatePhaseSequence, groupMissionStepsByPhase, @@ -3093,11 +3094,13 @@ export function createMissionService({ throw new Error("Invalid mission phase override payload."); } const selectedPhases = normalizePhaseCards( - overridePhasesRaw.length > 0 - ? overridePhasesRaw - : selectedProfile?.phases?.length - ? selectedProfile.phases - : createBuiltInPhaseCards(nowIso()) + ensurePlanningPhase( + overridePhasesRaw.length > 0 + ? overridePhasesRaw + : selectedProfile?.phases?.length + ? selectedProfile.phases + : createBuiltInPhaseCards(nowIso()) + ) ); const phaseErrors = validatePhaseSequence(selectedPhases); if (phaseErrors.length > 0) { diff --git a/apps/desktop/src/main/services/missions/phaseEngine.test.ts b/apps/desktop/src/main/services/missions/phaseEngine.test.ts index 7d7e342d8..2d669d83f 100644 --- a/apps/desktop/src/main/services/missions/phaseEngine.test.ts +++ b/apps/desktop/src/main/services/missions/phaseEngine.test.ts @@ -4,7 +4,9 @@ import { BUILT_IN_PHASE_KEYS, createBuiltInPhaseCards, createBuiltInPhaseProfiles, + ensurePlanningPhase, validatePhaseSequence, + resolveFirstPostPlanningPhaseKey, normalizeProfileInput, applyPhaseCardsToPlanSteps, groupMissionStepsByPhase, @@ -146,6 +148,39 @@ describe("createBuiltInPhaseProfiles", () => { }); }); +describe("ensurePlanningPhase", () => { + it("injects planning before a custom-only workflow", () => { + const auditPhase = makePhaseCard({ + id: "phase-audit", + phaseKey: "score_audit", + name: "Score audit", + isBuiltIn: false, + isCustom: true, + orderingConstraints: { mustBeFirst: true }, + position: 0, + }); + + const phases = ensurePlanningPhase([auditPhase], "2026-03-25T00:00:00.000Z"); + + expect(phases.map((phase) => phase.phaseKey)).toEqual(["planning", "score_audit"]); + expect(phases[0]?.orderingConstraints.mustBeFirst).toBe(true); + expect(phases[1]?.orderingConstraints.mustBeFirst).toBe(false); + expect(phases[1]?.orderingConstraints.mustFollow).toContain("planning"); + expect(validatePhaseSequence(phases)).toEqual([]); + }); + + it("resolves the configured phase immediately after planning", () => { + const cards = createBuiltInPhaseCards(); + const tddOrder = [ + cards.find((c) => c.phaseKey === "planning")!, + { ...cards.find((c) => c.phaseKey === "testing")!, position: 1 }, + { ...cards.find((c) => c.phaseKey === "development")!, position: 2 }, + ]; + + expect(resolveFirstPostPlanningPhaseKey(tddOrder)).toBe("testing"); + }); +}); + describe("validatePhaseSequence", () => { it("returns no errors for a valid default sequence", () => { const cards = createBuiltInPhaseCards(); diff --git a/apps/desktop/src/main/services/missions/phaseEngine.ts b/apps/desktop/src/main/services/missions/phaseEngine.ts index 76edb6c0d..bed67a861 100644 --- a/apps/desktop/src/main/services/missions/phaseEngine.ts +++ b/apps/desktop/src/main/services/missions/phaseEngine.ts @@ -60,6 +60,38 @@ export function resolveDevelopmentPhaseKey(phases: PhaseCard[]): string { return phase?.phaseKey ?? BUILT_IN_PHASE_KEYS.development; } +export function resolveFirstPostPlanningPhaseKey(phases: PhaseCard[]): string { + const sorted = [...phases].sort((a, b) => a.position - b.position); + const planningIndex = sorted.findIndex((phase) => normalizePhaseKey(phase.phaseKey) === BUILT_IN_PHASE_KEYS.planning); + const nextPhase = + sorted.find((phase, index) => index > planningIndex && normalizePhaseKey(phase.phaseKey) !== BUILT_IN_PHASE_KEYS.planning) + ?? sorted.find((phase) => normalizePhaseKey(phase.phaseKey) !== BUILT_IN_PHASE_KEYS.planning); + return nextPhase?.phaseKey ?? resolveDevelopmentPhaseKey(sorted); +} + +export function ensurePlanningPhase(phases: PhaseCard[], at: string = nowIso()): PhaseCard[] { + const sorted = [...phases].sort((a, b) => a.position - b.position); + if (sorted.some((phase) => normalizePhaseKey(phase.phaseKey) === BUILT_IN_PHASE_KEYS.planning)) { + return sorted.map((phase, index) => ({ ...phase, position: index })); + } + + const planningPhase = createBuiltInPhaseCards(at)[0]!; + return [planningPhase, ...sorted].map((phase, index) => { + if (index === 0) return { ...phase, position: index }; + if (!phase.orderingConstraints.mustBeFirst) return { ...phase, position: index }; + const mustFollow = new Set([...(phase.orderingConstraints.mustFollow ?? []), BUILT_IN_PHASE_KEYS.planning]); + return { + ...phase, + orderingConstraints: { + ...phase.orderingConstraints, + mustBeFirst: false, + mustFollow: [...mustFollow], + }, + position: index, + }; + }); +} + const DEFAULT_CLAUDE_PHASE_MODEL_ID = getDefaultModelDescriptor("claude")?.id ?? "anthropic/claude-sonnet-4-6"; const DEFAULT_CODEX_PHASE_MODEL_ID = getDefaultModelDescriptor("codex")?.id ?? "openai/gpt-5.5"; diff --git a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts index 9e5a2b983..ad18a9577 100644 --- a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts +++ b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts @@ -2010,6 +2010,130 @@ describe("aiOrchestratorService", () => { } }); + it("counts runtime review summaries toward closeout requirements", async () => { + const fixture = await createFixture(); + try { + const phase = { + id: "phase-review", + phaseKey: "review", + name: "Review", + description: "Review result lane.", + instructions: "Audit the result lane and report review_summary.", + model: { provider: "codex", modelId: "openai/gpt-5.3-codex-spark", thinkingLevel: "medium" }, + budget: {}, + orderingConstraints: {}, + askQuestions: { enabled: false }, + validationGate: { + tier: "dedicated", + required: true, + criteria: "Review passes.", + evidenceRequirements: ["review_summary"], + }, + requiresApproval: false, + isBuiltIn: false, + isCustom: true, + position: 0, + createdAt: "2026-05-05T00:00:00.000Z", + updatedAt: "2026-05-05T00:00:00.000Z", + } as const; + const mission = fixture.missionService.create({ + prompt: "Verify review summary evidence from runtime reports.", + laneId: fixture.laneId, + phaseOverride: [phase as any], + }); + const started = fixture.orchestratorService.startRun({ + missionId: mission.id, + steps: [ + { + stepKey: "planning-review-prompt", + title: "Planning worker", + stepIndex: 0, + dependencyStepKeys: [], + executorKind: "manual", + metadata: { + phaseKey: "planning", + stepType: "planning", + readOnlyExecution: true, + instructions: "Plan the mission and remember that closeout later needs review_summary.", + }, + }, + { + stepKey: "review-runtime-finalizer", + title: "Runtime Review phase finalizer", + stepIndex: 1, + dependencyStepKeys: [], + executorKind: "manual", + metadata: { + phaseKey: "validation", + phaseName: "Validation", + stepType: "validation", + taskType: "validation", + instructions: "Produce a runtime-visible Review phase result with review_summary.", + }, + }, + ], + }); + const step = fixture.orchestratorService.getRunGraph({ runId: started.run.id, timelineLimit: 0 }).steps[0]; + const reviewStep = fixture.orchestratorService.getRunGraph({ runId: started.run.id, timelineLimit: 0 }).steps.find((entry) => entry.stepKey === "review-runtime-finalizer"); + if (!step || !reviewStep) throw new Error("Expected seeded steps"); + fixture.db.run( + ` + update orchestrator_steps + set status = 'succeeded', + metadata_json = ?, + updated_at = ? + where id = ? + `, + [ + JSON.stringify({ + ...(step.metadata ?? {}), + lastResultReport: { + summary: "Planning summary should not satisfy review summary evidence.", + }, + }), + "2026-05-05T00:01:00.000Z", + step.id, + ], + ); + fixture.db.run( + ` + update orchestrator_steps + set status = 'succeeded', + metadata_json = ?, + updated_at = ? + where id = ? + `, + [ + JSON.stringify({ + ...(reviewStep.metadata ?? {}), + validationContract: { required: true }, + validationState: "pass", + validationPassedAt: "2026-05-05T00:01:00.000Z", + lastValidationReport: { verdict: "pass" }, + lastResultReport: { + summary: "Review audit passed. Acceptance criteria and proof evidence were confirmed.", + }, + }), + "2026-05-05T00:01:00.000Z", + reviewStep.id, + ], + ); + fixture.db.run( + `update orchestrator_runs set status = 'succeeded', completed_at = ?, updated_at = ? where id = ?`, + ["2026-05-05T00:01:00.000Z", "2026-05-05T00:01:00.000Z", started.run.id], + ); + + const view = await fixture.aiOrchestratorService.getRunView({ missionId: mission.id, runId: started.run.id }); + const reviewSummary = view?.closeoutRequirements.find((entry) => entry.key === "review_summary"); + + expect(reviewSummary?.status).toBe("present"); + expect(reviewSummary?.source).toBe("runtime"); + expect(reviewSummary?.detail).toContain("Review audit passed"); + } finally { + fixture.dispose(); + } + }); + it("keeps a succeeded run blocked while required closeout evidence is missing", async () => { const logger = createLogger(); logger.info = vi.fn(); @@ -6571,7 +6695,7 @@ describe("aiOrchestratorService", () => { const finalizeResult = fixture.aiOrchestratorService.finalizeRun({ runId }); expect(finalizeResult.finalized).toBe(true); - await fixture.aiOrchestratorService.syncMissionFromRun(runId, "test_final_sync"); + await waitFor(() => fixture.missionService.get(mission.id)?.status === "completed"); const refreshed = fixture.missionService.get(mission.id); expect(refreshed?.status).toBe("completed"); expect(refreshed?.steps.every((step) => step.status === "succeeded")).toBe(true); diff --git a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts index 3bedd554c..79030957c 100644 --- a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts +++ b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts @@ -262,7 +262,7 @@ import { dispatchOrchestratorHookCtx, maybeDispatchTeammateIdleHookCtx, } from "./missionLifecycle"; -import { resolveDevelopmentPhaseKey } from "../missions/phaseEngine"; +import { resolveFirstPostPlanningPhaseKey } from "../missions/phaseEngine"; import type { HookDispatchDeps } from "./missionLifecycle"; import { hasMaterialWorkerChatEvent } from "../../../shared/chatTranscript"; import { @@ -326,7 +326,7 @@ export function buildCoordinatorEvaluationActionHints(graph: OrchestratorRunGrap const hints: string[] = []; const executionSteps = filterExecutionSteps(graph.steps); if (executionSteps.length === 0) return hints; - const nextImplementationPhaseKey = resolveDevelopmentPhaseKey( + const nextImplementationPhaseKey = resolveFirstPostPlanningPhaseKey( deriveConfiguredPhasesForSync(graph) as unknown as PhaseCard[], ); @@ -817,6 +817,7 @@ export function createAiOrchestratorService(args: { let agentChatService = initialAgentChatService ?? null; const plannerMemoryService = createMemoryService(db); const syncLocks = new Set(); + const syncInFlight = new Map>(); const workerStates = new Map(); const activeSteeringDirectives = new Map(); const runRuntimeProfiles = new Map(); @@ -2920,6 +2921,7 @@ Check all worker statuses and continue managing the mission from here. Read work const details: string[] = []; collectRiskLikeStrings(report.risks, details); collectRiskLikeStrings(report.riskNotes, details); + collectRiskLikeStrings(report.risk_notes, details); collectRiskLikeStrings(report.warnings, details); collectRiskSummary(report.summary, details); return details; @@ -2930,6 +2932,7 @@ Check all worker statuses and continue managing the mission from here. Read work const details: string[] = []; collectRiskLikeStrings(payload.risks, details); collectRiskLikeStrings(payload.riskNotes, details); + collectRiskLikeStrings(payload.risk_notes, details); collectRiskLikeStrings(payload.warnings, details); collectRiskSummary(payload.summary, details); return details; @@ -2941,6 +2944,9 @@ Check all worker statuses and continue managing the mission from here. Read work && (phase.validationGate.evidenceRequirements ?? []) .some((entry) => mapEvidenceRequirementToCloseoutKey(entry) === "risk_notes") ); + const retrospective = args.stateDoc?.latestRetrospective ?? null; + const retrospectiveHasNoUnresolvedRisks = + retrospective?.finalStatus === "succeeded" && retrospective.unresolvedRisks.length === 0; if (allRuntimeRiskDetails.length > 0) { runtimeEvidenceByKey.set("risk_notes", { artifactId: null, @@ -2948,7 +2954,7 @@ Check all worker statuses and continue managing the mission from here. Read work detail: clipTextForContext(allRuntimeRiskDetails[0], 600), source: "runtime", }); - } else if (args.graph.run.status === "succeeded" && !phaseRequiresRiskNotes) { + } else if (args.graph.run.status === "succeeded" && (!phaseRequiresRiskNotes || retrospectiveHasNoUnresolvedRisks)) { runtimeEvidenceByKey.set("risk_notes", { artifactId: null, uri: null, @@ -2957,6 +2963,86 @@ Check all worker statuses and continue managing the mission from here. Read work }); } + const collectReviewSummaryStrings = (value: unknown, details: string[]): void => { + if (typeof value === "string" && value.trim().length > 0) { + details.push(value.trim()); + } else if (Array.isArray(value)) { + for (const entry of value) collectReviewSummaryStrings(entry, details); + } + }; + const isReviewEvidenceText = (value: string): boolean => + /\breview\b/i.test(value) && ( + /\baudit\b/i.test(value) + || /\bacceptance\b/i.test(value) + || /\bvalidation\b/i.test(value) + || /\bproof\b/i.test(value) + || /review[_\s-]?summary/i.test(value) + ); + const runtimeReviewSummaryDetails = args.graph.steps.flatMap((step) => { + const meta = isRecord(step.metadata) ? step.metadata : {}; + const report = isRecord(meta.lastResultReport) ? meta.lastResultReport : null; + const validationReport = isRecord(meta.lastValidationReport) ? meta.lastValidationReport : null; + const identityText = [ + step.stepKey, + step.title, + typeof meta.workerName === "string" ? meta.workerName : "", + typeof meta.phaseKey === "string" ? meta.phaseKey : "", + typeof meta.phaseName === "string" ? meta.phaseName : "", + typeof meta.stepType === "string" ? meta.stepType : "", + typeof meta.taskType === "string" ? meta.taskType : "", + ].join(" "); + const planningLike = + meta.readOnlyExecution === true + || (typeof meta.phaseKey === "string" && meta.phaseKey.trim().toLowerCase() === "planning") + || (typeof meta.stepType === "string" && meta.stepType.trim().toLowerCase() === "planning"); + const reviewRelated = !planningLike && /\breview\b/i.test(identityText); + const details: string[] = []; + if (report) { + collectReviewSummaryStrings(report.reviewSummary, details); + collectReviewSummaryStrings(report.review_summary, details); + const summary = typeof report.summary === "string" ? report.summary.trim() : ""; + if (summary && !planningLike && (reviewRelated || isReviewEvidenceText(summary))) details.push(summary); + } + if (validationReport) { + collectReviewSummaryStrings(validationReport.reviewSummary, details); + collectReviewSummaryStrings(validationReport.review_summary, details); + const summary = typeof validationReport.summary === "string" ? validationReport.summary.trim() : ""; + if (summary && !planningLike && (reviewRelated || isReviewEvidenceText(summary))) details.push(summary); + } + return details; + }); + const runtimeReviewEventDetails = (args.graph.runtimeEvents ?? []).flatMap((event) => { + if ( + event.eventType !== "worker_result_report" + && event.eventType !== "validation_report" + && event.eventType !== "done" + ) { + return []; + } + const payload = isRecord(event.payload) ? event.payload : {}; + const workerId = typeof payload.workerId === "string" ? payload.workerId : ""; + const summary = typeof payload.summary === "string" ? payload.summary.trim() : ""; + const reviewRelated = /\breview\b/i.test(`${workerId} ${summary}`); + const planningRelated = /\bplanning\b/i.test(`${workerId} ${summary}`); + const details: string[] = []; + collectReviewSummaryStrings(payload.reviewSummary, details); + collectReviewSummaryStrings(payload.review_summary, details); + if (summary && !planningRelated && (reviewRelated || isReviewEvidenceText(summary))) details.push(summary); + return details; + }); + const allRuntimeReviewSummaryDetails = [ + ...runtimeReviewSummaryDetails, + ...runtimeReviewEventDetails, + ]; + if (allRuntimeReviewSummaryDetails.length > 0) { + runtimeEvidenceByKey.set("review_summary", { + artifactId: null, + uri: null, + detail: clipTextForContext(allRuntimeReviewSummaryDetails[0], 600), + source: "runtime", + }); + } + const pushRequirement = (requirement: MissionCloseoutRequirement): void => { closeoutRequirements.set(requirement.key, requirement); }; @@ -5364,6 +5450,16 @@ Check all worker statuses and continue managing the mission from here. Read work retrospectiveGenerated: Boolean(retrospective) }); + if (finalStatus === "succeeded") { + void syncMissionFromRun(runId, "finalize_run_succeeded").catch((error) => { + logger.debug("ai_orchestrator.finalize_sync_failed", { + runId, + missionId, + error: error instanceof Error ? error.message : String(error), + }); + }); + } + return finalized; }; @@ -6887,8 +6983,22 @@ Check all worker statuses and continue managing the mission from here. Read work reason: string, options?: { nextMissionStatus?: MissionStatus | null } ) => { - if (!runId || syncLocks.has(runId)) return; - syncLocks.add(runId); + if (!runId) return; + const activeSync = syncInFlight.get(runId); + if (activeSync) { + await activeSync; + if (options?.nextMissionStatus) { + await syncMissionFromRun(runId, reason, options); + } + return; + } + let resolveSync!: () => void; + const executeSync = new Promise((resolve) => { + resolveSync = resolve; + }); + syncInFlight.set(runId, executeSync); + void (async () => { + syncLocks.add(runId); try { const graph = orchestratorService.getRunGraph({ runId, timelineLimit: 120 }); const mission = missionService.get(graph.run.missionId); @@ -7181,7 +7291,11 @@ Check all worker statuses and continue managing the mission from here. Read work }); } finally { syncLocks.delete(runId); + syncInFlight.delete(runId); + resolveSync(); } + })(); + await executeSync; }; // ── Project Docs Discovery ────────────────────────────────── @@ -11149,6 +11263,7 @@ Check all worker statuses and continue managing the mission from here. Read work runWatchdogTimers.delete(runId); } syncLocks.clear(); + syncInFlight.clear(); workerStates.clear(); activeSteeringDirectives.clear(); runRuntimeProfiles.clear(); diff --git a/apps/desktop/src/main/services/orchestrator/coordinatorAgent.ts b/apps/desktop/src/main/services/orchestrator/coordinatorAgent.ts index ec6774cee..ca91e6245 100644 --- a/apps/desktop/src/main/services/orchestrator/coordinatorAgent.ts +++ b/apps/desktop/src/main/services/orchestrator/coordinatorAgent.ts @@ -2584,6 +2584,22 @@ export class CoordinatorAgent { .some((step) => !TERMINAL_STEP_STATUSES.has(step.status)); } + private phaseMatchesRef(phase: PhaseCard, ref: string): boolean { + const normalizedRef = ref.trim().toLowerCase(); + if (!normalizedRef) return false; + return phase.phaseKey.trim().toLowerCase() === normalizedRef || phase.name.trim().toLowerCase() === normalizedRef; + } + + private findPhaseByRef(phases: PhaseCard[], ref: string): PhaseCard | null { + return phases.find((phase) => this.phaseMatchesRef(phase, ref)) ?? null; + } + + private resolveMustPrecedePredecessors(phases: PhaseCard[], targetPhase: PhaseCard): PhaseCard[] { + return phases.filter((phase) => + (phase.orderingConstraints.mustPrecede ?? []).some((successorRef) => this.phaseMatchesRef(targetPhase, successorRef)) + ); + } + private resolvePhaseDependencyStepKeys( graph: ReturnType, phase: PhaseCard, @@ -2596,11 +2612,14 @@ export class CoordinatorAgent { const predecessorPhases = explicitMustFollow.length > 0 ? explicitMustFollow - .map((key) => sorted.find((candidate) => candidate.phaseKey === key || candidate.name === key) ?? null) + .map((key) => this.findPhaseByRef(sorted, key)) .filter((candidate): candidate is PhaseCard => Boolean(candidate)) : sorted.slice(0, Math.max(0, targetIndex)).filter((candidate) => candidate.orderingConstraints.mustBeFirst || candidate.validationGate.required === true ); + for (const predecessor of this.resolveMustPrecedePredecessors(sorted, phase)) { + if (!predecessorPhases.includes(predecessor)) predecessorPhases.push(predecessor); + } const dependencyKeys: string[] = []; for (const predecessor of predecessorPhases) { const successful = this.getExecutionStepsForPhaseFromGraph(graph, predecessor) @@ -2634,9 +2653,12 @@ export class CoordinatorAgent { if (phase.orderingConstraints.mustBeLast && this.phaseHasOpenExecutionStepInGraph(graph, earlier)) return false; } for (const predecessorKey of explicitMustFollow) { - const predecessor = sorted.find((candidate) => candidate.phaseKey === predecessorKey || candidate.name === predecessorKey); + const predecessor = this.findPhaseByRef(sorted, predecessorKey); if (predecessor && !this.phaseHasValidatedSuccess(graph, predecessor)) return false; } + for (const predecessor of this.resolveMustPrecedePredecessors(sorted, phase)) { + if (!this.phaseHasValidatedSuccess(graph, predecessor)) return false; + } return true; } diff --git a/apps/desktop/src/main/services/orchestrator/coordinatorEventFormatter.ts b/apps/desktop/src/main/services/orchestrator/coordinatorEventFormatter.ts index f00c72568..ce7c0bddc 100644 --- a/apps/desktop/src/main/services/orchestrator/coordinatorEventFormatter.ts +++ b/apps/desktop/src/main/services/orchestrator/coordinatorEventFormatter.ts @@ -11,7 +11,7 @@ import type { OrchestratorStepStatus, PhaseCard, } from "../../../shared/types"; -import { resolveDevelopmentPhaseKey } from "../missions/phaseEngine"; +import { resolveFirstPostPlanningPhaseKey } from "../missions/phaseEngine"; // --------------------------------------------------------------------------- // Types @@ -158,7 +158,7 @@ function buildCoordinatorActionHints( || runPhase.phaseName === "planning"; if (planningPhase && step?.status === "succeeded") { - const nextPhaseKey = resolveDevelopmentPhaseKey(resolveGraphPhaseCards(graph)); + const nextPhaseKey = resolveFirstPostPlanningPhaseKey(resolveGraphPhaseCards(graph)); hints.push( `Coordinator action: this completion is still labeled Planning. Review the worker output, create/update the visible DAG if needed, then call set_current_phase with phaseKey "${nextPhaseKey}" before spawning any code-changing worker.` ); diff --git a/apps/desktop/src/main/services/orchestrator/coordinatorTools.test.ts b/apps/desktop/src/main/services/orchestrator/coordinatorTools.test.ts index 93e31a569..47c21b30a 100644 --- a/apps/desktop/src/main/services/orchestrator/coordinatorTools.test.ts +++ b/apps/desktop/src/main/services/orchestrator/coordinatorTools.test.ts @@ -91,6 +91,29 @@ describe("coordinator project context", () => { }); }); +function testPhase(overrides: Record = {}) { + const phaseKey = overrides.phaseKey ?? "development"; + const name = overrides.name ?? "Development"; + return { + id: overrides.id ?? `phase-${phaseKey}`, + phaseKey, + name, + description: overrides.description ?? `${name} phase`, + instructions: overrides.instructions ?? `Run the ${name} phase.`, + model: overrides.model ?? { modelId: "openai/gpt-5.5", provider: "codex", thinkingLevel: "medium" }, + budget: overrides.budget ?? {}, + orderingConstraints: overrides.orderingConstraints ?? {}, + askQuestions: overrides.askQuestions ?? { enabled: false }, + validationGate: overrides.validationGate ?? { tier: "none", required: false }, + requiresApproval: overrides.requiresApproval ?? false, + isBuiltIn: overrides.isBuiltIn ?? false, + isCustom: overrides.isCustom ?? true, + position: overrides.position ?? 0, + createdAt: overrides.createdAt ?? "2026-03-02T00:00:00.000Z", + updatedAt: overrides.updatedAt ?? "2026-03-02T00:00:00.000Z", + }; +} + describe("coordinator memory tools", () => { it("memory_search queries project memory with mission scope defaults", async () => { const memoryService = { @@ -1294,6 +1317,217 @@ describe("coordinatorTools task planning", () => { expect(orchestratorService.addSteps).not.toHaveBeenCalled(); }); + it("steers post-planning workers to the configured next phase", async () => { + const planning = testPhase({ + id: "phase-planning", + phaseKey: "planning", + name: "Planning", + position: 0, + isBuiltIn: true, + isCustom: false, + model: { modelId: "anthropic/claude-sonnet-4-6", provider: "claude", thinkingLevel: "medium" }, + orderingConstraints: { mustBeFirst: true }, + }); + const testing = testPhase({ + id: "phase-testing", + phaseKey: "testing", + name: "Testing", + position: 1, + isBuiltIn: true, + isCustom: false, + validationGate: { tier: "dedicated", required: true }, + }); + const development = testPhase({ + id: "phase-development", + phaseKey: "development", + name: "Development", + position: 2, + isBuiltIn: true, + isCustom: false, + }); + const { tools, orchestratorService } = createCoordinatorHarness({ + graph: { + run: { + metadata: { + phaseRuntime: { + currentPhaseKey: "planning", + currentPhaseName: "Planning", + currentPhaseModel: planning.model, + }, + }, + }, + steps: [ + { + id: "step-plan-1", + stepKey: "worker-plan", + stepIndex: 0, + title: "Planning worker", + laneId: null, + status: "succeeded", + dependencyStepIds: [], + retryLimit: 1, + retryCount: 0, + metadata: { phaseKey: "planning", phaseName: "Planning", stepType: "planning", readOnlyExecution: true }, + }, + ], + attempts: [], + }, + missionMetadata: { phaseConfiguration: { selectedPhases: [planning, testing, development] } }, + }); + + const result = await (tools.spawn_worker as any).execute({ + name: "test-runner", + prompt: "Run the test suite and report failures.", + dependsOn: [], + }); + + expect(result).toMatchObject({ + ok: false, + blockedByPhaseOrdering: true, + phaseTransitionRequired: { + phaseKey: "testing", + reason: "planning_complete", + }, + }); + expect(orchestratorService.addSteps).not.toHaveBeenCalled(); + }); + + it("blocks target phase drift until the coordinator transitions phases", async () => { + const development = testPhase({ phaseKey: "development", name: "Development", position: 0 }); + const testing = testPhase({ + phaseKey: "testing", + name: "Testing", + position: 1, + validationGate: { tier: "dedicated", required: true }, + }); + const { tools, orchestratorService } = createCoordinatorHarness({ + graph: { + run: { + metadata: { + phaseRuntime: { + currentPhaseKey: "development", + currentPhaseName: "Development", + currentPhaseModel: development.model, + }, + }, + }, + steps: [], + attempts: [], + }, + missionMetadata: { phaseConfiguration: { selectedPhases: [development, testing] } }, + }); + + const result = await (tools.spawn_worker as any).execute({ + name: "test-runner", + prompt: "Run targeted tests before implementation is finished.", + dependsOn: [], + }); + + expect(result).toMatchObject({ + ok: false, + blockedByPhaseOrdering: true, + phaseTransitionRequired: { + phaseKey: "testing", + reason: "target_phase_required", + }, + }); + expect(orchestratorService.addSteps).not.toHaveBeenCalled(); + }); + + it("enforces mustPrecede before entering successor phases", async () => { + const development = testPhase({ phaseKey: "development", name: "Development", position: 0 }); + const security = testPhase({ + phaseKey: "security_review", + name: "Security review", + position: 1, + orderingConstraints: { mustPrecede: ["release"] }, + }); + const release = testPhase({ phaseKey: "release", name: "Release", position: 2 }); + const { tools, db } = createCoordinatorHarness({ + graph: { + run: { + metadata: { + phaseRuntime: { + currentPhaseKey: "development", + currentPhaseName: "Development", + currentPhaseModel: development.model, + }, + }, + }, + steps: [ + { + id: "step-dev-1", + stepKey: "worker-dev", + stepIndex: 0, + title: "Development worker", + laneId: null, + status: "succeeded", + dependencyStepIds: [], + retryLimit: 1, + retryCount: 0, + metadata: { phaseKey: "development", phaseName: "Development", stepType: "implementation" }, + }, + ], + attempts: [], + }, + missionMetadata: { phaseConfiguration: { selectedPhases: [development, security, release] } }, + }); + + const result = await (tools.set_current_phase as any).execute({ phaseKey: "release" }); + + expect(result).toMatchObject({ + ok: false, + error: "Cannot enter phase 'Release' until 'Security review' succeeds (mustPrecede).", + }); + expect(db.run).not.toHaveBeenCalled(); + }); + + it("allows validation-looking workers in custom validation-capable phases", async () => { + const review = testPhase({ + phaseKey: "security_review", + name: "Security review", + position: 0, + validationGate: { + tier: "dedicated", + required: true, + criteria: "Security review passes.", + evidenceRequirements: ["review_summary", "risk_notes"], + }, + }); + const { tools, orchestratorService } = createCoordinatorHarness({ + graph: { + run: { + metadata: { + phaseRuntime: { + currentPhaseKey: "security_review", + currentPhaseName: "Security review", + currentPhaseModel: review.model, + }, + }, + }, + steps: [], + attempts: [], + }, + missionMetadata: { phaseConfiguration: { selectedPhases: [review] } }, + }); + + const result = await (tools.spawn_worker as any).execute({ + name: "security-validator", + prompt: "Validate the result lane and produce review summary plus risk notes.", + dependsOn: [], + }); + + expect(result).toMatchObject({ ok: true }); + expect(orchestratorService.addSteps).toHaveBeenCalledWith(expect.objectContaining({ + steps: [expect.objectContaining({ + metadata: expect.objectContaining({ + phaseKey: "security_review", + phaseName: "Security review", + }), + })], + })); + }); + it("report_status tolerates omitted optional arrays from runtime tool calls", async () => { const graph = { run: { metadata: {} }, @@ -1634,19 +1868,14 @@ describe("coordinatorTools task planning", () => { }); expect(result).toMatchObject({ - ok: true, - delegationContract: expect.objectContaining({ + ok: false, + blockedByPhaseOrdering: true, + phaseTransitionRequired: { phaseKey: "testing", - scope: expect.objectContaining({ key: "phase:testing" }), - }), - }); - const addedStepInput = orchestratorService.addSteps.mock.calls.at(-1)?.[0]?.steps?.[0]; - expect(addedStepInput?.metadata).toMatchObject({ - phaseKey: "testing", - phaseName: "Testing", - stepType: "testing", - taskType: "testing", + reason: "target_phase_required", + }, }); + expect(orchestratorService.addSteps).not.toHaveBeenCalled(); }); }); @@ -2882,7 +3111,7 @@ describe("coordinatorTools planning manual-input blocking", () => { expect(result).toMatchObject({ ok: false, - error: expect.stringContaining('Validation workers can only be spawned during the "validation" phase.'), + error: expect.stringContaining("Validation workers can only be spawned during a validation-capable phase."), }); expect(orchestratorService.addSteps).not.toHaveBeenCalled(); }); @@ -2902,7 +3131,25 @@ describe("coordinatorTools budget hard-cap guards", () => { } } }, - steps: [], + steps: [ + { + id: "step-planning", + stepKey: "plan", + stepIndex: 0, + title: "Planning", + laneId: null, + status: "succeeded", + dependencyStepIds: [], + retryLimit: 1, + retryCount: 0, + metadata: { + phaseKey: "planning", + phaseName: "Planning", + stepType: "planning", + validationState: "pass", + }, + }, + ], attempts: [], }, }); @@ -4210,7 +4457,25 @@ describe("coordinatorTools validation enforcement", () => { }, }, }, - steps: [], + steps: [ + { + id: "step-planning", + stepKey: "plan", + stepIndex: 0, + title: "Planning", + laneId: null, + status: "succeeded", + dependencyStepIds: [], + retryLimit: 1, + retryCount: 0, + metadata: { + phaseKey: "planning", + phaseName: "Planning", + stepType: "planning", + validationState: "pass", + }, + }, + ], attempts: [], }, }); @@ -4223,7 +4488,11 @@ describe("coordinatorTools validation enforcement", () => { expect(result).toMatchObject({ ok: false, - error: expect.stringContaining('Validation workers can only be spawned during the "validation" phase.'), + blockedByPhaseOrdering: true, + phaseTransitionRequired: { + phaseKey: "validation", + reason: "target_phase_required", + }, }); expect(orchestratorService.addSteps).not.toHaveBeenCalled(); }); @@ -4293,7 +4562,11 @@ describe("coordinatorTools validation enforcement", () => { expect(result).toMatchObject({ ok: false, - error: expect.stringContaining('Validation workers can only be spawned during the "validation" phase.'), + blockedByPhaseOrdering: true, + phaseTransitionRequired: { + phaseKey: "validation", + reason: "target_phase_required", + }, }); expect(orchestratorService.addSteps).not.toHaveBeenCalled(); }); diff --git a/apps/desktop/src/main/services/orchestrator/coordinatorTools.ts b/apps/desktop/src/main/services/orchestrator/coordinatorTools.ts index f76107644..3486d9bb4 100644 --- a/apps/desktop/src/main/services/orchestrator/coordinatorTools.ts +++ b/apps/desktop/src/main/services/orchestrator/coordinatorTools.ts @@ -61,7 +61,7 @@ import { hasConflictingDelegationContract, updateDelegationContract, } from "./delegationContracts"; -import { resolveDevelopmentPhaseKey } from "../missions/phaseEngine"; +import { resolveFirstPostPlanningPhaseKey } from "../missions/phaseEngine"; /** Timeout for autopilot agent startup (Promise.race guard). */ const AUTOPILOT_START_TIMEOUT_MS = 15_000; @@ -1652,8 +1652,37 @@ export function createCoordinatorToolSet(deps: { }; } + function normalizePhaseRef(value: string | null | undefined): string { + return typeof value === "string" ? value.trim().toLowerCase() : ""; + } + + function phaseMatchesRef(phase: PhaseCard, ref: string): boolean { + const normalized = normalizePhaseRef(ref); + if (!normalized) return false; + return normalizePhaseRef(phase.phaseKey) === normalized || normalizePhaseRef(phase.name) === normalized; + } + + function findPhaseByRef(phases: PhaseCard[], ref: string): PhaseCard | null { + return phases.find((phase) => phaseMatchesRef(phase, ref)) ?? null; + } + + function resolveMustPrecedePredecessors(phases: PhaseCard[], targetPhase: PhaseCard): PhaseCard[] { + return phases.filter((phase) => + (phase.orderingConstraints.mustPrecede ?? []).some((successorRef) => phaseMatchesRef(targetPhase, successorRef)) + ); + } + + function phaseAllowsValidationWorker(phase: PhaseCard | null | undefined): boolean { + if (!phase) return false; + const stepType = resolvePhaseStepType(phase.phaseKey); + if (stepType === "validation" || stepType === "review" || stepType === "test_review") return true; + const gate = phase.validationGate; + return gate.required === true || gate.tier === "dedicated"; + } + function validateDelegationPromptForCurrentPhase(args: { currentPhaseKey: string; + currentPhase?: PhaseCard | null; nextImplementationPhaseKey: string; workerName: string; roleName: string | null; @@ -1671,7 +1700,7 @@ export function createCoordinatorToolSet(deps: { } const validationHeuristics = args.validationHeuristics ?? "strict"; if ( - args.currentPhaseKey !== "validation" + !phaseAllowsValidationWorker(args.currentPhase) && looksLikeValidationWorkerRequest({ name: args.workerName, roleName: validationHeuristics === "strict" ? args.roleName : null, @@ -1684,8 +1713,8 @@ export function createCoordinatorToolSet(deps: { return { ok: false, error: - 'Validation workers can only be spawned during the "validation" phase. ' + - 'Finish the active work, call set_current_phase with phaseKey "validation", and depend on the worker you are validating.' + 'Validation workers can only be spawned during a validation-capable phase. ' + + 'Finish the active work, call set_current_phase with the configured validation/review phase, and depend on the worker you are validating.' }; } return { ok: true }; @@ -2409,7 +2438,7 @@ export function createCoordinatorToolSet(deps: { }; } if (planningExecutionSteps.some((step) => step.status === "succeeded")) { - const nextPhaseKey = resolveDevelopmentPhaseKey(sorted); + const nextPhaseKey = resolveFirstPostPlanningPhaseKey(sorted); return { valid: false, reason: `Planning phase already produced a completed worker result. Call set_current_phase with phaseKey "${nextPhaseKey}" before spawning more workers.`, @@ -2424,7 +2453,7 @@ export function createCoordinatorToolSet(deps: { .map((entry) => entry.trim()) .filter((entry) => entry.length > 0); for (const predecessor of explicitMustFollow) { - const predecessorPhase = sorted.find((p) => p.phaseKey === predecessor || p.name === predecessor); + const predecessorPhase = findPhaseByRef(sorted, predecessor); if (predecessorPhase && !phaseHasSucceeded(predecessorPhase)) { return { valid: false, @@ -2433,6 +2462,16 @@ export function createCoordinatorToolSet(deps: { } } + const mustPrecedePredecessors = resolveMustPrecedePredecessors(sorted, currentPhase); + for (const predecessorPhase of mustPrecedePredecessors) { + if (!phaseHasSucceeded(predecessorPhase)) { + return { + valid: false, + reason: `Phase "${currentPhase.name}" cannot start until phase "${predecessorPhase.name}" succeeds (mustPrecede constraint).`, + }; + } + } + // Without explicit mustFollow, preserve positional sequencing for required phases. // With explicit mustFollow, the custom phase DAG owns the dependency graph. for (let i = 0; i < currentIndex; i++) { @@ -2611,7 +2650,8 @@ export function createCoordinatorToolSet(deps: { requestedDependsOn.length > 0 ? requestedDependsOn : inferredDependsOn; const phaseValidation = validateDelegationPromptForCurrentPhase({ currentPhaseKey, - nextImplementationPhaseKey: resolveDevelopmentPhaseKey(missionPhases), + currentPhase, + nextImplementationPhaseKey: resolveFirstPostPlanningPhaseKey(missionPhases), workerName: normalizedName, roleName: normalizedRole.length > 0 ? normalizedRole : null, prompt: normalizedPrompt, @@ -2683,6 +2723,18 @@ export function createCoordinatorToolSet(deps: { } return { ok: false, error: spawnPolicy.error }; } + if ( + missionPhases.length > 0 + && currentPhase + && targetPhase + && targetPhase.phaseKey !== currentPhase.phaseKey + ) { + return phaseOrderingBlockedResult( + `Worker '${normalizedName}' targets phase "${targetPhase.name}", but the current phase is "${currentPhase.name}". Call set_current_phase with phaseKey "${targetPhase.phaseKey}" before spawning this worker.`, + targetPhase.phaseKey, + "target_phase_required", + ); + } let resolvedModelId = spawnPolicy.resolvedModelId; let resolvedProvider = spawnPolicy.resolvedProvider; const explicitModelId = typeof modelId === "string" ? modelId.trim() : ""; @@ -2798,7 +2850,7 @@ export function createCoordinatorToolSet(deps: { && step.status === "succeeded"; }); if (completedPlanningWorker) { - const nextPhaseKey = resolveDevelopmentPhaseKey(missionPhases); + const nextPhaseKey = resolveFirstPostPlanningPhaseKey(missionPhases); return phaseOrderingBlockedResult( `Planning phase already produced a completed worker result. Call set_current_phase with phaseKey "${nextPhaseKey}" before spawning more workers.`, nextPhaseKey, @@ -3239,7 +3291,8 @@ export function createCoordinatorToolSet(deps: { const currentPhaseKey = currentPhase?.phaseKey.trim().toLowerCase() ?? ""; const phaseValidation = validateDelegationPromptForCurrentPhase({ currentPhaseKey, - nextImplementationPhaseKey: resolveDevelopmentPhaseKey(missionPhases), + currentPhase, + nextImplementationPhaseKey: resolveFirstPostPlanningPhaseKey(missionPhases), workerName, roleName: null, prompt: normalizedObjective, @@ -5408,7 +5461,7 @@ Format: Lead with the concrete rule or fact, then brief context for WHY. One act } for (const predecessorKey of explicitMustFollow) { - const predecessor = missionPhases.find((phase) => phase.phaseKey === predecessorKey || phase.name === predecessorKey); + const predecessor = findPhaseByRef(missionPhases, predecessorKey); if (predecessor && !hasValidatedSuccessfulCompletion(predecessor)) { return { ok: false, @@ -5417,6 +5470,15 @@ Format: Lead with the concrete rule or fact, then brief context for WHY. One act } } + for (const predecessor of resolveMustPrecedePredecessors(missionPhases, targetPhase)) { + if (!hasValidatedSuccessfulCompletion(predecessor)) { + return { + ok: false, + error: `Cannot enter phase '${targetPhase.name}' until '${predecessor.name}' succeeds (mustPrecede).` + }; + } + } + // ── VAL-PLAN-005 / VAL-PLAN-007: Approval gate ── // Before leaving any phase with requiresApproval=true, require user approval. if (currentPhase && currentPhase.requiresApproval === true) { @@ -5584,7 +5646,7 @@ Format: Lead with the concrete rule or fact, then brief context for WHY. One act const currentPhase = missionPhases.length > 0 ? resolveCurrentPhaseCard(g, missionPhases) : null; const currentPhaseKey = currentPhase?.phaseKey.trim().toLowerCase() ?? ""; if (currentPhaseKey === "planning") { - const nextPhaseKey = resolveDevelopmentPhaseKey(missionPhases); + const nextPhaseKey = resolveFirstPostPlanningPhaseKey(missionPhases); return phaseOrderingBlockedResult( "Planning should be represented by the planning worker itself. " + `Spawn the read-only planning worker, review its output, then call set_current_phase with phaseKey "${nextPhaseKey}" before creating execution tasks.`, @@ -7027,7 +7089,8 @@ Format: Lead with the concrete rule or fact, then brief context for WHY. One act const currentPhaseKey = currentPhase?.phaseKey.trim().toLowerCase() ?? ""; const phaseValidation = validateDelegationPromptForCurrentPhase({ currentPhaseKey, - nextImplementationPhaseKey: resolveDevelopmentPhaseKey(missionPhases), + currentPhase, + nextImplementationPhaseKey: resolveFirstPostPlanningPhaseKey(missionPhases), workerName: name, roleName: null, prompt, @@ -7298,7 +7361,8 @@ Format: Lead with the concrete rule or fact, then brief context for WHY. One act } const phaseValidation = validateDelegationPromptForCurrentPhase({ currentPhaseKey, - nextImplementationPhaseKey: resolveDevelopmentPhaseKey(missionPhases), + currentPhase, + nextImplementationPhaseKey: resolveFirstPostPlanningPhaseKey(missionPhases), workerName: taskName, roleName: null, prompt: taskPrompt, diff --git a/apps/desktop/src/main/services/orchestrator/executionPolicy.test.ts b/apps/desktop/src/main/services/orchestrator/executionPolicy.test.ts index 51d69ea98..911a83157 100644 --- a/apps/desktop/src/main/services/orchestrator/executionPolicy.test.ts +++ b/apps/desktop/src/main/services/orchestrator/executionPolicy.test.ts @@ -25,7 +25,8 @@ import { roleForStepType, validateRoleIsolation, contextViewForRole, - evaluateRecoveryLoop + evaluateRecoveryLoop, + buildExecutionPlanPreviewFromPhases } from "./executionPolicy"; function makeStep(overrides: Partial & { id: string; status: OrchestratorStepStatus }): OrchestratorStep { @@ -557,6 +558,81 @@ describe("evaluateRunCompletionFromPhases", () => { ); }); + it("counts runtime review finalizers as Review phase completion evidence", () => { + const phases = [ + makePhaseCard({ phaseKey: "implementation", validationGate: { tier: "none", required: true }, position: 0 }), + makePhaseCard({ phaseKey: "validation", validationGate: { tier: "dedicated", required: true }, position: 1 }), + makePhaseCard({ + phaseKey: "review", + name: "Review", + validationGate: { tier: "dedicated", required: true }, + position: 2 + }) + ]; + const steps = [ + makeStep({ + id: "impl", + status: "succeeded", + metadata: { stepType: "code", phaseKey: "implementation" } + }), + makeStep({ + id: "validate-acceptance", + status: "succeeded", + metadata: { + stepType: "validation", + taskType: "validation", + phaseKey: "validation", + validationState: "pass" + } + }), + makeStep({ + id: "review-phase-record", + status: "skipped", + metadata: { + phaseKey: "review", + phaseName: "Review", + stepType: "task", + taskType: "review", + isTask: true, + displayOnlyTask: true, + skippedReason: "Administrative runtime record only; actual Review work is complete in review-runtime-finalizer." + } + }), + makeStep({ + id: "review-runtime-finalizer", + title: "Runtime Review phase finalizer", + status: "succeeded", + metadata: { + stepType: "validation", + taskType: "validation", + phaseKey: "validation", + phaseName: "Validation", + delegationIntent: "validation", + instructions: + "Produce and validate a runtime-visible Review phase result. Include review_summary and risk_notes before closeout.", + validationState: "pass", + lastValidationReport: { + verdict: "pass", + summary: "Review phase validation passed with review_summary and risk_notes." + } + } + }) + ]; + + const result = evaluateRunCompletionFromPhases(steps, phases, defaultSettings); + + expect(result.completionReady).toBe(true); + expect(result.status).toBe("succeeded"); + expect(result.diagnostics).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + phase: "codeReview", + code: "phase_required_missing" + }) + ]) + ); + }); + it("returns active when a required phase is missing", () => { const phases = [ makePhaseCard({ phaseKey: "implementation", validationGate: { tier: "none", required: true } }), @@ -678,6 +754,52 @@ describe("evaluateRunCompletionFromPhases", () => { }); }); +describe("buildExecutionPlanPreviewFromPhases", () => { + it("preserves custom phases in preview order", () => { + const phases = [ + makePhaseCard({ phaseKey: "planning", name: "Planning", position: 0 }), + makePhaseCard({ + phaseKey: "release", + name: "Release readiness", + position: 1, + validationGate: { tier: "dedicated", required: true }, + isBuiltIn: false, + isCustom: true, + }), + ]; + + const preview = buildExecutionPlanPreviewFromPhases({ + runId: "run-1", + missionId: "mission-1", + phases, + steps: [ + { + stepKey: "release-check", + title: "Release check", + role: "validator" as OrchestratorWorkerRole, + executorKind: "opencode", + model: "openai/gpt-5.5", + laneId: "lane-1", + dependencies: [], + phase: "release", + }, + ], + settings: { prStrategy: { kind: "manual" } }, + teamManifest: { + workers: [{ role: "validator" }], + parallelLanes: [["lane-1"]], + } as any, + }); + + expect(preview.phases.map((phase) => phase.phase)).toEqual(["release"]); + expect(preview.phases[0]).toMatchObject({ + phase: "release", + gatePolicy: "required", + stepCount: 1, + }); + }); +}); + // ───────────────────────────────────────────────────── // validateRunCompletion // ───────────────────────────────────────────────────── diff --git a/apps/desktop/src/main/services/orchestrator/executionPolicy.ts b/apps/desktop/src/main/services/orchestrator/executionPolicy.ts index 1ec941f4e..6d90661fe 100644 --- a/apps/desktop/src/main/services/orchestrator/executionPolicy.ts +++ b/apps/desktop/src/main/services/orchestrator/executionPolicy.ts @@ -872,6 +872,101 @@ function shouldCountDisplayOnlyPhaseRecordForCompletion( return Boolean(resolvedTargetKey && phaseTargets.has(resolvedTargetKey)); } +function splitCamelWords(value: string): string { + return value.replace(/([a-z0-9])([A-Z])/g, "$1 $2"); +} + +function normalizeSearchableText(value: string | null | undefined): string { + return typeof value === "string" + ? splitCamelWords(value).replace(/[_-]+/g, " ").replace(/\s+/g, " ").trim().toLowerCase() + : ""; +} + +function stepHasPassingValidation(step: OrchestratorStep): boolean { + const meta = step.metadata ?? {}; + const lastValidationReport = + typeof meta.lastValidationReport === "object" && meta.lastValidationReport + ? (meta.lastValidationReport as Record) + : null; + const lastVerdict = typeof lastValidationReport?.verdict === "string" ? lastValidationReport.verdict : ""; + const validationState = typeof meta.validationState === "string" ? meta.validationState : ""; + const validationPassedAt = typeof meta.validationPassedAt === "string" ? meta.validationPassedAt.trim() : ""; + return isPassingValidationValue(lastVerdict) || isPassingValidationValue(validationState) || validationPassedAt.length > 0; +} + +function isValidationSurrogateCandidate(step: OrchestratorStep): boolean { + if (step.status !== "succeeded") return false; + const meta = step.metadata ?? {}; + const stepType = normalizeSearchableText(typeof meta.stepType === "string" ? meta.stepType : ""); + const taskType = normalizeSearchableText(typeof meta.taskType === "string" ? meta.taskType : ""); + const phaseKey = normalizeSearchableText(typeof meta.phaseKey === "string" ? meta.phaseKey : ""); + const phaseName = normalizeSearchableText(typeof meta.phaseName === "string" ? meta.phaseName : ""); + const delegationIntent = normalizeSearchableText(typeof meta.delegationIntent === "string" ? meta.delegationIntent : ""); + return ( + stepHasPassingValidation(step) + && ( + stepType === "validation" + || taskType === "validation" + || phaseKey === "validation" + || phaseName === "validation" + || delegationIntent === "validation" + ) + ); +} + +function phaseTargetIdentities(target: PhaseEvaluationTarget): string[] { + const identities = [ + target.key, + splitCamelWords(target.key), + target.label, + ]; + if (target.key === "codeReview") identities.push("review", "code review"); + if (target.key === "testReview") identities.push("test review"); + return identities + .map(normalizeSearchableText) + .filter((entry, index, all) => entry.length > 0 && all.indexOf(entry) === index); +} + +function validationSurrogateSearchText(step: OrchestratorStep): string { + const meta = step.metadata ?? {}; + const lastResultReport = + typeof meta.lastResultReport === "object" && meta.lastResultReport + ? (meta.lastResultReport as Record) + : null; + const lastValidationReport = + typeof meta.lastValidationReport === "object" && meta.lastValidationReport + ? (meta.lastValidationReport as Record) + : null; + const values = [ + step.stepKey, + step.title, + typeof meta.instructions === "string" ? meta.instructions : "", + typeof meta.workerName === "string" ? meta.workerName : "", + typeof lastResultReport?.summary === "string" ? lastResultReport.summary : "", + typeof lastValidationReport?.summary === "string" ? lastValidationReport.summary : "", + ]; + return normalizeSearchableText(values.join(" ")); +} + +function canUseValidationStepAsPhaseCompletionSurrogate( + step: OrchestratorStep, + target: PhaseEvaluationTarget, +): boolean { + if (target.key === "implementation" || target.key === "testing" || target.key === "validation" || target.key === "integration") { + return false; + } + if (!isValidationSurrogateCandidate(step)) return false; + + const searchable = validationSurrogateSearchText(step); + const referencesTargetPhase = phaseTargetIdentities(target).some((identity) => searchable.includes(identity)); + if (!referencesTargetPhase) return false; + + if (target.key === "codeReview" || target.key === "testReview") { + return searchable.includes("review summary") && searchable.includes("risk notes"); + } + return true; +} + const BUILT_IN_EXECUTION_PHASES: ExecutionPhase[] = [ "implementation", "testing", @@ -1034,6 +1129,18 @@ export function evaluateRunCompletionFromPhases( phaseSteps.set(resolvedTargetKey, bucket); } + for (const [phase, target] of phaseTargets) { + if (!target.required) continue; + const currentSteps = phaseSteps.get(phase) ?? []; + if (phaseHasConcreteSuccess(currentSteps.map((step) => step.status))) continue; + const surrogateSteps = relevantSteps.filter((step) => + canUseValidationStepAsPhaseCompletionSurrogate(step, target) + ); + if (surrogateSteps.length > 0) { + phaseSteps.set(phase, [...currentSteps, ...surrogateSteps]); + } + } + const evaluationOrder = [...BUILT_IN_EXECUTION_PHASES, ...customPhaseOrder]; for (const phase of evaluationOrder) { @@ -1173,24 +1280,26 @@ export function buildExecutionPlanPreviewFromPhases(args: { const recoveryPolicy: RecoveryLoopPolicy = settings.recoveryLoop ?? DEFAULT_RECOVERY_LOOP_POLICY; - // Build phase details from phase cards + // Build phase details from phase cards, preserving custom workflow phases. const phaseDetails: ExecutionPlanPhase[] = []; - const cardsByPhaseKey = new Map(); - for (const card of phases) { - const ep = phaseKeyToExecutionPhase(card.phaseKey); - if (ep) cardsByPhaseKey.set(ep, card); + const phaseOrder: Array<{ phaseName: string; card: PhaseCard | null }> = []; + const seenPhases = new Set(); + for (const card of [...phases].sort((a, b) => a.position - b.position)) { + const phaseName = phaseKeyToExecutionPhase(card.phaseKey) ?? card.phaseKey; + if (phaseName === "planning" || seenPhases.has(phaseName)) continue; + seenPhases.add(phaseName); + phaseOrder.push({ phaseName, card }); + } + for (const phaseName of phaseMap.keys()) { + if (seenPhases.has(phaseName)) continue; + seenPhases.add(phaseName); + phaseOrder.push({ phaseName, card: null }); } - const phaseOrder = [ - "implementation", "testing", "validation", - "codeReview", "testReview", "integration" - ]; - - for (const phaseName of phaseOrder) { + for (const { phaseName, card } of phaseOrder) { const phaseSteps = phaseMap.get(phaseName); if (!phaseSteps || phaseSteps.length === 0) continue; - const card = cardsByPhaseKey.get(phaseName); const gatePolicy = card?.validationGate.required ? "required" : (card?.validationGate.tier ?? "none"); const recoveryPhases = new Set(["testing", "codeReview", "testReview", "validation"]); diff --git a/apps/desktop/src/main/services/orchestrator/orchestratorAdapters.test.ts b/apps/desktop/src/main/services/orchestrator/orchestratorAdapters.test.ts index 3f4bb983e..53efb8a67 100644 --- a/apps/desktop/src/main/services/orchestrator/orchestratorAdapters.test.ts +++ b/apps/desktop/src/main/services/orchestrator/orchestratorAdapters.test.ts @@ -575,6 +575,11 @@ describe("providerOrchestratorAdapter", () => { status: "ready", metadata: { modelId: "openai/gpt-5.3-codex", + phaseModel: { + modelId: "openai/gpt-5.3-codex", + provider: "codex", + thinkingLevel: "low", + }, }, }, attempt: { @@ -611,9 +616,85 @@ describe("providerOrchestratorAdapter", () => { }), startupCommand: expect.stringContaining("exec codex"), })); + expect(createTrackedSession).toHaveBeenCalledWith(expect.objectContaining({ + startupCommand: expect.stringContaining('model_reasoning_effort="low"'), + })); const firstCreateArgs = (createTrackedSession.mock.calls as any[])[0]?.[0]; + const launchSpec = JSON.parse(fs.readFileSync(firstCreateArgs?.args?.[2], "utf8")); + expect(launchSpec.args).toEqual(expect.arrayContaining([ + "-c", + 'model_reasoning_effort="low"', + ])); expect(firstCreateArgs?.startupCommand).toContain("< "); }); + + it("keeps read-only Codex CLI workers in a read-only sandbox even when full-auto is configured", async () => { + projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-provider-adapter-")); + const previousRpcUrl = process.env.ADE_RPC_URL; + process.env.ADE_RPC_URL = "http://127.0.0.1:65535"; + const createTrackedSession = vi.fn(async () => ({ ptyId: "pty-1", sessionId: "session-1" })); + const adapter = createProviderOrchestratorAdapter({ + projectRoot, + workspaceRoot: projectRoot, + agentChatService: null, + }); + + try { + const result = await adapter.start({ + run: { + id: "run-1", + missionId: "mission-1", + metadata: {}, + }, + step: { + id: "step-1", + runId: "run-1", + stepKey: "codex-planner", + title: "Codex planner", + stepIndex: 0, + dependencyStepIds: [], + dependencyStepKeys: [], + laneId: "lane-1", + status: "ready", + metadata: { + modelId: "openai/gpt-5.3-codex", + readOnlyExecution: true, + }, + }, + attempt: { + id: "attempt-read-only", + runId: "run-1", + stepId: "step-1", + }, + allSteps: [], + contextProfile: {} as any, + laneExport: null, + projectExport: { content: "Project context", truncated: false }, + docsRefs: [], + fullDocs: [], + createTrackedSession, + permissionConfig: { + _providers: { + codex: "full-auto", + codexSandbox: "danger-full-access", + }, + }, + } as any); + + expect(result.status).toBe("accepted"); + const firstCreateArgs = (createTrackedSession.mock.calls as any[])[0]?.[0]; + const launchSpec = JSON.parse(fs.readFileSync(firstCreateArgs?.args?.[2], "utf8")); + expect(launchSpec.args).toEqual(expect.arrayContaining(["-s", "read-only"])); + expect(firstCreateArgs?.startupCommand).toMatch(/-s '?read-only'?/); + expect(firstCreateArgs?.startupCommand).not.toContain("danger-full-access"); + } finally { + if (previousRpcUrl == null) { + delete process.env.ADE_RPC_URL; + } else { + process.env.ADE_RPC_URL = previousRpcUrl; + } + } + }); }); describe("permissionMapping", () => { diff --git a/apps/desktop/src/main/services/orchestrator/orchestratorService.test.ts b/apps/desktop/src/main/services/orchestrator/orchestratorService.test.ts index d123b21ae..bfb129822 100644 --- a/apps/desktop/src/main/services/orchestrator/orchestratorService.test.ts +++ b/apps/desktop/src/main/services/orchestrator/orchestratorService.test.ts @@ -6364,6 +6364,186 @@ describe("orchestratorService", () => { } }); + it("recovers durable step output before classifying an open shell command as interrupted", async () => { + const fixture = await createFixture(); + try { + const now = "2026-02-19T00:00:00.000Z"; + const transcriptDir = path.join(fixture.projectRoot, ".ade", "transcripts"); + fs.mkdirSync(transcriptDir, { recursive: true }); + const preSessionId = "session-1"; + const transcriptPath = path.join(transcriptDir, `${preSessionId}.log`); + fixture.db.run( + `insert or ignore into terminal_sessions( + id, lane_id, pty_id, tracked, title, started_at, ended_at, + exit_code, transcript_path, head_sha_start, head_sha_end, + status, last_output_preview, summary, tool_type, resume_command, last_output_at + ) values (?, ?, null, 1, 'Worker', ?, null, null, ?, null, null, + 'running', null, null, 'codex-orchestrated', null, ?)`, + [preSessionId, fixture.laneId, now, transcriptPath, now] + ); + + const started = fixture.service.startRun({ + missionId: fixture.missionId, + steps: [ + { + stepKey: "implementation-worker", + title: "Implementation Worker", + stepIndex: 0, + laneId: fixture.laneId, + executorKind: "opencode", + metadata: { + stepType: "implementation", + }, + } + ] + }); + const stepId = fixture.service.listSteps(started.run.id)[0]?.id; + if (!stepId) throw new Error("Expected implementation step"); + + const attempt = await fixture.service.startAttempt({ + runId: started.run.id, + stepId, + ownerId: "operator" + }); + if (!attempt.executorSessionId) throw new Error("Expected running session-backed attempt"); + + fs.mkdirSync(path.join(fixture.projectRoot, ".ade"), { recursive: true }); + fs.writeFileSync( + path.join(fixture.projectRoot, ".ade", "step-output-implementation-worker.md"), + [ + "## Summary", + "Implemented the preference banner and validated the helper.", + "", + "## Files Changed", + "- `app/page.tsx`", + "- `lib/preference-banner.ts`", + "", + "## Tests", + "- `npm test` passed: 3 failed: 0 skipped: 0", + "", + "## Validation", + "- `npm run typecheck` passed", + ].join("\n"), + "utf8" + ); + fs.writeFileSync( + transcriptPath, + [ + "codex", + "I’m sending the result through the CLI.", + "exec", + "/bin/zsh -lc 'ade coordinator report_result --payload @/tmp/result.json' in /tmp/mission-lane", + ].join("\n"), + "utf8" + ); + + const reconciled = await fixture.service.onTrackedSessionEnded({ + sessionId: attempt.executorSessionId, + laneId: fixture.laneId, + exitCode: 0 + }); + expect(reconciled).toBe(1); + + const after = fixture.service.listAttempts({ runId: started.run.id }).find((entry) => entry.id === attempt.id); + expect(after?.status).toBe("succeeded"); + expect(after?.errorClass).toBe("none"); + const [stepAfter] = fixture.service.listSteps(started.run.id); + const metadata = stepAfter?.metadata as Record; + const report = metadata.lastResultReport as Record; + expect(metadata.recoveredResultReportFromStepOutput).toBe(true); + expect(report.summary).toBe("Implemented the preference banner and validated the helper."); + expect(report.filesChanged).toEqual(["app/page.tsx", "lib/preference-banner.ts"]); + expect(report.testsRun).toMatchObject({ command: "npm test", passed: 3, failed: 0, skipped: 0 }); + } finally { + fixture.dispose(); + } + }); + + it("does not recover report_result prompt templates as worker results", async () => { + const fixture = await createFixture(); + try { + const now = "2026-02-19T00:00:00.000Z"; + const transcriptDir = path.join(fixture.projectRoot, ".ade", "transcripts"); + fs.mkdirSync(transcriptDir, { recursive: true }); + const preSessionId = "session-1"; + const transcriptPath = path.join(transcriptDir, `${preSessionId}.log`); + fixture.db.run( + `insert or ignore into terminal_sessions( + id, lane_id, pty_id, tracked, title, started_at, ended_at, + exit_code, transcript_path, head_sha_start, head_sha_end, + status, last_output_preview, summary, tool_type, resume_command, last_output_at + ) values (?, ?, null, 1, 'Worker', ?, null, null, ?, null, null, + 'running', null, null, 'codex-orchestrated', null, ?)`, + [preSessionId, fixture.laneId, now, transcriptPath, now] + ); + + const started = fixture.service.startRun({ + missionId: fixture.missionId, + steps: [ + { + stepKey: "planning-worker", + title: "Planning worker", + stepIndex: 0, + laneId: fixture.laneId, + executorKind: "opencode", + metadata: { + phaseKey: "planning", + stepType: "planning", + }, + } + ] + }); + const stepId = fixture.service.listSteps(started.run.id)[0]?.id; + if (!stepId) throw new Error("Expected planning step"); + + const attempt = await fixture.service.startAttempt({ + runId: started.run.id, + stepId, + ownerId: "operator" + }); + if (!attempt.executorSessionId) throw new Error("Expected running session-backed attempt"); + + fs.writeFileSync( + transcriptPath, + [ + "## Planning worker report contract", + "If the runtime exposes mission control through transcript recovery instead of a callable tool, finish with this exact section shape:", + "### report_result", + "- outcome: succeeded", + "- summary: ", + "- filesChanged: []", + "- testsRun: ", + "- plan.markdown:", + " ```markdown", + " ## Plan", + " - What was learned", + " - Recommended next steps", + " - Risks or stop conditions", + " ```", + "/bin/zsh -lc 'ade coordinator report_result --payload @/tmp/result.json' in /tmp/mission-lane", + ].join("\n"), + "utf8" + ); + + const reconciled = await fixture.service.onTrackedSessionEnded({ + sessionId: attempt.executorSessionId, + laneId: fixture.laneId, + exitCode: 0 + }); + expect(reconciled).toBe(1); + + const after = fixture.service.listAttempts({ runId: started.run.id }).find((entry) => entry.id === attempt.id); + expect(after?.status).toBe("failed"); + expect(after?.errorClass).toBe("interrupted"); + expect(after?.errorMessage).toBe("Planning worker session was interrupted while a shell command was still running before report_result.plan.markdown."); + const [stepAfter] = fixture.service.listSteps(started.run.id); + const metadata = stepAfter?.metadata as Record; + expect(metadata.lastResultReport).toBeUndefined(); + } finally { + fixture.dispose(); + } + }); + it("derives tracked-session completion status from terminal session state when exit code is missing", async () => { const fixture = await createFixture(); try { diff --git a/apps/desktop/src/main/services/orchestrator/orchestratorService.ts b/apps/desktop/src/main/services/orchestrator/orchestratorService.ts index b207183c9..d5bf35a85 100644 --- a/apps/desktop/src/main/services/orchestrator/orchestratorService.ts +++ b/apps/desktop/src/main/services/orchestrator/orchestratorService.ts @@ -328,6 +328,11 @@ function getWorkerCheckpointPath(worktreePath: string, stepKey: string): string return path.join(worktreePath, ".ade", "checkpoints", `${sanitizedStepKey}.md`); } +function getWorkerStepOutputPath(worktreePath: string, stepKey: string): string { + const sanitizedStepKey = stepKey.replace(/[^a-zA-Z0-9_-]/g, "_"); + return path.join(worktreePath, ".ade", `step-output-${sanitizedStepKey}.md`); +} + type NormalizedValidationContract = { level: "step" | "milestone" | "mission"; tier: "self" | "dedicated"; @@ -1079,13 +1084,39 @@ function sliceLikelyCodexResultText(text: string): string { function looksLikeReportTemplatePlaceholder(value: string): boolean { const normalized = value.trim().toLowerCase(); return ( - normalized === "1-2 sentence description of what was accomplished." + normalized === "" + || normalized === "." + || normalized === "" + || /^<[^>\r\n]+>$/.test(normalized) + || normalized === "1-2 sentence description of what was accomplished." || normalized.includes("1-2 sentence description of what was accomplished") || normalized.includes("bulleted list of files created or modified") || normalized.includes("test results if any tests were run") ); } +function looksLikePlanTemplatePlaceholder(value: string): boolean { + const normalized = value.trim().toLowerCase(); + if (!normalized.length) return false; + const templateLabels = [ + "what was learned", + "recommended next steps", + "risks or stop conditions", + ]; + const lines = normalized + .replace(/^```(?:markdown|md)?\s*/, "") + .replace(/\s*```\s*$/, "") + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .filter((line) => !/^#{1,6}\s*plan\s*:?\s*$/.test(line)) + .map((line) => line.replace(/^\s*(?:[-*]|\d+[.)])\s*/, "").replace(/[:.]$/, "").trim()); + return ( + lines.length === templateLabels.length + && templateLabels.every((label) => lines.includes(label)) + ); +} + function firstNonPlaceholderMarkdownValue(...values: string[]): string { return values.find((value) => value.length > 0 && !looksLikeReportTemplatePlaceholder(value)) ?? ""; } @@ -1375,6 +1406,17 @@ function extractReportResultPayloadFromTranscript(filePath: string | null | unde } } +function extractReportResultPayloadFromStepOutput(filePath: string | null | undefined): Record | null { + const normalizedPath = typeof filePath === "string" ? filePath.trim() : ""; + if (!normalizedPath || !fs.existsSync(normalizedPath)) return null; + try { + const rawTail = readUtf8Tail(normalizedPath, REPORT_RESULT_TRANSCRIPT_TAIL_BYTES); + return extractReportResultPayloadFromText(rawTail); + } catch { + return null; + } +} + function normalizeRecoveredWorkerResultReport(args: { payload: Record; step: OrchestratorStep; @@ -1388,24 +1430,36 @@ function normalizeRecoveredWorkerResultReport(args: { ? rawOutcome : "succeeded"; const rawPlan = asRecord(args.payload.plan); - const planMarkdown = typeof rawPlan?.markdown === "string" ? rawPlan.markdown.trim() : ""; + const planMarkdownRaw = typeof rawPlan?.markdown === "string" ? rawPlan.markdown.trim() : ""; + const planMarkdown = + planMarkdownRaw.length > 0 + && !looksLikeReportTemplatePlaceholder(planMarkdownRaw) + && !looksLikePlanTemplatePlaceholder(planMarkdownRaw) + ? planMarkdownRaw + : ""; + const planSummary = + typeof rawPlan?.summary === "string" && rawPlan.summary.trim().length > 0 && !looksLikeReportTemplatePlaceholder(rawPlan.summary) + ? rawPlan.summary.trim() + : ""; const normalizedPlan = rawPlan && planMarkdown.length > 0 ? { markdown: planMarkdown, - ...(typeof rawPlan.summary === "string" && rawPlan.summary.trim().length > 0 ? { summary: rawPlan.summary.trim() } : {}), + ...(planSummary.length > 0 ? { summary: planSummary } : {}), ...(typeof rawPlan.title === "string" && rawPlan.title.trim().length > 0 ? { title: rawPlan.title.trim() } : {}), ...(rawPlan.format === "markdown" ? { format: "markdown" as const } : {}), ...(typeof rawPlan.artifactPath === "string" && rawPlan.artifactPath.trim().length > 0 ? { artifactPath: rawPlan.artifactPath.trim() } : {}), } : null; - const summary = + if (isPlanningLikeStepMetadata(asRecord(args.step.metadata)) && !normalizedPlan) return null; + const rawSummary = typeof args.payload.summary === "string" && args.payload.summary.trim().length > 0 ? args.payload.summary.trim() - : typeof rawPlan?.summary === "string" && rawPlan.summary.trim().length > 0 - ? rawPlan.summary.trim() + : planSummary.length > 0 + ? planSummary : normalizedPlan ? "Planning step completed." : ""; + const summary = looksLikeReportTemplatePlaceholder(rawSummary) ? "" : rawSummary; if (!summary && !normalizedPlan) return null; const artifactsRaw = Array.isArray(args.payload.artifacts) ? args.payload.artifacts : []; const filesChangedRaw = Array.isArray(args.payload.filesChanged) @@ -6947,7 +7001,7 @@ export function createOrchestratorService({ touchedRunIds.add(attempt.run_id); const stepRow = getStepRow(attempt.step_id); const step = stepRow ? toStep(stepRow) : null; - const stepMetadata = asRecord(step?.metadata) ?? {}; + let stepMetadata = asRecord(step?.metadata) ?? {}; const attemptMetadata = parseJsonRecord(attempt.metadata_json); const transcriptPath = typeof attemptMetadata?.transcriptPath === "string" @@ -6955,13 +7009,97 @@ export function createOrchestratorService({ : (typeof sessionRow?.transcript_path === "string" ? sessionRow.transcript_path.trim() : ""); const transcriptAnalysis = analyzeTranscriptFromPath(transcriptPath); const transcriptSummary = transcriptAnalysis.summary; - const silentFailure = classifySilentWorkerExit({ - stepMetadata, - transcriptSummary, - hasMaterialOutput: transcriptAnalysis.hasMaterialOutput, - hasLifecycleActivity: transcriptAnalysis.hasLifecycleActivity, - appearsInterruptedDuringCommand: transcriptAnalysis.appearsInterruptedDuringCommand, - }); + let hasStructuredResult = asRecord(stepMetadata.lastResultReport) != null; + if (!hasStructuredResult && step) { + let recoveredFrom: "transcript" | "step_output" | null = null; + let recoveredPayload = extractReportResultPayloadFromTranscript(transcriptPath); + if (recoveredPayload) { + recoveredFrom = "transcript"; + } else if (step.laneId) { + const laneRow = db.get<{ worktree_path: string | null }>( + `select worktree_path from lanes where id = ? and project_id = ? limit 1`, + [step.laneId, projectId] + ); + const worktreePath = typeof laneRow?.worktree_path === "string" && laneRow.worktree_path.trim().length > 0 + ? laneRow.worktree_path.trim() + : null; + if (worktreePath) { + const stepOutputPath = getWorkerStepOutputPath(worktreePath, step.stepKey); + recoveredPayload = extractReportResultPayloadFromStepOutput(stepOutputPath); + if (recoveredPayload) recoveredFrom = "step_output"; + } + } + + const runForAttempt = recoveredPayload ? getRunRow(attempt.run_id) : null; + const recoveredReport = recoveredPayload && runForAttempt + ? normalizeRecoveredWorkerResultReport({ + payload: recoveredPayload, + step, + attempt: toAttempt(attempt), + missionId: runForAttempt.mission_id, + runId: attempt.run_id, + }) + : null; + if (recoveredReport) { + const recoveredFromTranscript = recoveredFrom === "transcript"; + const recoveredFromStepOutput = recoveredFrom === "step_output"; + stepMetadata = { + ...stepMetadata, + lastResultReport: recoveredReport, + ...(recoveredFromTranscript ? { recoveredResultReportFromTranscript: true } : {}), + ...(recoveredFromStepOutput ? { recoveredResultReportFromStepOutput: true } : {}), + }; + this.updateStepMetadata({ + runId: attempt.run_id, + stepId: attempt.step_id, + metadata: stepMetadata, + allowTerminal: true, + }); + this.appendRuntimeEvent({ + runId: attempt.run_id, + stepId: attempt.step_id, + attemptId: attempt.id, + sessionId, + eventType: "worker_result_report", + payload: { + ...recoveredReport, + recoveredFromTranscript, + recoveredFromStepOutput, + } as unknown as Record, + }); + this.appendTimelineEvent({ + runId: attempt.run_id, + stepId: attempt.step_id, + attemptId: attempt.id, + eventType: "worker_result_reported", + reason: recoveredFromStepOutput ? "step_output_report_result" : "transcript_report_result", + detail: { + ...recoveredReport, + recoveredFromTranscript, + recoveredFromStepOutput, + } as unknown as Record, + }); + this.createHandoff({ + missionId: recoveredReport.missionId, + runId: attempt.run_id, + stepId: attempt.step_id, + attemptId: attempt.id, + handoffType: "worker_result_report", + producer: step.stepKey, + payload: recoveredReport as unknown as Record, + }); + hasStructuredResult = true; + } + } + const silentFailure = hasStructuredResult + ? null + : classifySilentWorkerExit({ + stepMetadata, + transcriptSummary, + hasMaterialOutput: transcriptAnalysis.hasMaterialOutput, + hasLifecycleActivity: transcriptAnalysis.hasLifecycleActivity, + appearsInterruptedDuringCommand: transcriptAnalysis.appearsInterruptedDuringCommand, + }); const completionForAttempt = completion.status === "succeeded" && silentFailure ? { @@ -9096,7 +9234,23 @@ export function createOrchestratorService({ : null; let lastResultReport = asRecord(stepMetadata.lastResultReport); if (!lastResultReport && status === "succeeded") { - const recoveredPayload = extractReportResultPayloadFromTranscript(transcriptPath); + let recoveredFrom: "transcript" | "step_output" | null = null; + let recoveredPayload = extractReportResultPayloadFromTranscript(transcriptPath); + if (recoveredPayload) { + recoveredFrom = "transcript"; + } else if (step.laneId) { + const laneRow = db.get<{ worktree_path: string | null }>( + `select worktree_path from lanes where id = ? and project_id = ? limit 1`, + [step.laneId, projectId] + ); + const worktreePath = typeof laneRow?.worktree_path === "string" && laneRow.worktree_path.trim().length + ? laneRow.worktree_path.trim() + : null; + if (worktreePath) { + recoveredPayload = extractReportResultPayloadFromStepOutput(getWorkerStepOutputPath(worktreePath, step.stepKey)); + if (recoveredPayload) recoveredFrom = "step_output"; + } + } const recoveredReport = recoveredPayload ? normalizeRecoveredWorkerResultReport({ payload: recoveredPayload, @@ -9107,10 +9261,13 @@ export function createOrchestratorService({ }) : null; if (recoveredReport) { + const recoveredFromTranscript = recoveredFrom === "transcript"; + const recoveredFromStepOutput = recoveredFrom === "step_output"; stepMetadata = { ...stepMetadata, lastResultReport: recoveredReport, - recoveredResultReportFromTranscript: true, + ...(recoveredFromTranscript ? { recoveredResultReportFromTranscript: true } : {}), + ...(recoveredFromStepOutput ? { recoveredResultReportFromStepOutput: true } : {}), }; this.updateStepMetadata({ runId: run.id, @@ -9126,7 +9283,8 @@ export function createOrchestratorService({ eventType: "worker_result_report", payload: { ...recoveredReport, - recoveredFromTranscript: true, + recoveredFromTranscript, + recoveredFromStepOutput, } as unknown as Record, }); this.appendTimelineEvent({ @@ -9134,10 +9292,11 @@ export function createOrchestratorService({ stepId: step.id, attemptId: attempt.id, eventType: "worker_result_reported", - reason: "transcript_report_result", + reason: recoveredFromStepOutput ? "step_output_report_result" : "transcript_report_result", detail: { ...recoveredReport, - recoveredFromTranscript: true, + recoveredFromTranscript, + recoveredFromStepOutput, } as unknown as Record, }); this.createHandoff({ diff --git a/apps/desktop/src/main/services/orchestrator/providerOrchestratorAdapter.ts b/apps/desktop/src/main/services/orchestrator/providerOrchestratorAdapter.ts index 493c4cfe1..0e4cdb1ac 100644 --- a/apps/desktop/src/main/services/orchestrator/providerOrchestratorAdapter.ts +++ b/apps/desktop/src/main/services/orchestrator/providerOrchestratorAdapter.ts @@ -596,21 +596,12 @@ export function createProviderOrchestratorAdapter(options?: { if (descriptor?.isCliWrapped && descriptor.family === "openai") { // Codex CLI path — use per-provider permission when available - const originalProviderPermissions = normalizeMissionPermissions(permissionConfig as MissionPermissionConfig | undefined); - const allowReadOnlyControlPlaneFullAuto = - readOnlyExecution - && originalProviderPermissions.codex === "full-auto" - && typeof process.env.ADE_RPC_URL === "string" - && process.env.ADE_RPC_URL.trim().length > 0; + const reasoningEffort = resolveStepReasoningEffort(step.metadata); const codexProviderMode = effectivePermissionConfig?._providers?.codex; const mappedCodex = mapPermissionToCodex(codexProviderMode); const useCodexConfig = codexProviderMode === "config-toml" || mappedCodex == null; - const approvalPolicy = allowReadOnlyControlPlaneFullAuto - ? "never" - : mappedCodex?.approvalPolicy ?? "on-request"; - const sandboxMode = allowReadOnlyControlPlaneFullAuto - ? "danger-full-access" - : readOnlyExecution + const approvalPolicy = mappedCodex?.approvalPolicy ?? "on-request"; + const sandboxMode = readOnlyExecution ? "read-only" : codexProviderMode === "full-auto" ? mappedCodex?.sandbox ?? "danger-full-access" @@ -622,6 +613,11 @@ export function createProviderOrchestratorAdapter(options?: { const previewParts: string[] = [ "codex", "--model", shellEscapeArg(resolveCodexCliModel(descriptor.providerModelId)), ]; + if (reasoningEffort) { + const reasoningConfig = `model_reasoning_effort="${reasoningEffort}"`; + commandArgs.push("-c", reasoningConfig); + previewParts.push("-c", shellEscapeArg(reasoningConfig)); + } if (!useCodexConfig) { commandArgs.push("-a", approvalPolicy, "-s", sandboxMode); previewParts.push("-a", shellEscapeArg(approvalPolicy), "-s", shellEscapeArg(sandboxMode)); diff --git a/docs/features/missions/README.md b/docs/features/missions/README.md index 0637acd1e..0ac540af8 100644 --- a/docs/features/missions/README.md +++ b/docs/features/missions/README.md @@ -69,7 +69,7 @@ All under `apps/desktop/src/renderer/components/missions/`: ### Planning is mandatory -Planning is the first-class initial phase. If a phase profile omits a planning phase, `createBuiltInPhaseCards()` injects one before execution begins. The coordinator: +Planning is the first-class initial phase. If a phase profile omits a planning phase, mission launch normalizes the selected phase deck with an injected Planning phase before execution begins. The coordinator: 1. Gathers project context (`fetching_project_context`). 2. Optionally asks clarifying questions. From bbe94e9621f6409f34b19480e1063a8434b33743 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sat, 16 May 2026 02:15:29 -0400 Subject: [PATCH 2/4] Address mission workflow review feedback --- apps/ade-cli/src/adeRpcServer.test.ts | 4 + apps/ade-cli/src/cli.test.ts | 17 +++++ apps/ade-cli/src/cli.ts | 35 ++++----- .../src/main/services/adeActions/registry.ts | 13 +++- .../aiOrchestratorService.test.ts | 4 +- .../orchestrator/aiOrchestratorService.ts | 76 +++++++++++-------- .../orchestrator/baseOrchestratorAdapter.ts | 7 +- .../orchestrator/coordinatorTools.test.ts | 9 ++- .../services/orchestrator/coordinatorTools.ts | 55 +++++++++----- .../services/orchestrator/executionPolicy.ts | 34 ++++++++- .../orchestrator/orchestratorService.test.ts | 2 +- .../orchestrator/orchestratorService.ts | 26 +++++-- 12 files changed, 195 insertions(+), 87 deletions(-) diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index 0d17a3939..da71c31f9 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -3562,6 +3562,10 @@ describe("adeRpcServer", () => { skipped: 0, raw: expect.stringContaining("npm test (passed: 2 files, 3 tests)") }); + const raw = String(response.structuredContent.report.testsRun.raw ?? ""); + expect(raw).toContain("npm run typecheck (passed)"); + expect(raw).toContain("npm test (passed: 2 files, 3 tests)"); + expect(raw).toContain("ADE_PROJECT_ROOT=/tmp/app npm run build (passed)"); }); }); diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index 1d70dad3b..3205d3579 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -862,6 +862,23 @@ describe("ADE CLI", () => { details: "Running npm run typecheck", }, }); + + const statusOnly = buildCliPlan([ + "report_status", + "--status", + "failed", + "--arg", + "details=structured payload", + ]); + expect(statusOnly.kind).toBe("execute"); + if (statusOnly.kind !== "execute") return; + expect(statusOnly.steps[0]?.params).toEqual({ + name: "report_status", + arguments: { + status: "failed", + details: "structured payload", + }, + }); }); it("rejects invalid JSON action shapes before execution", () => { diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index a07f4559e..cd0a9cb6c 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -2268,18 +2268,22 @@ function buildWorkerMissionToolPlan(name: string, args: string[]): CliPlan { }); } if (name === "report_status") { - const message = - readValue(args, ["--text", "--message", "--summary"]) ?? - args - .filter((entry) => entry !== "--" && !entry.startsWith("-")) - .join(" ") - .trim(); - return collectGenericObjectArgs(args, { - status: readValue(args, ["--status"]) ?? "running", - summary: message || undefined, - nextAction: message || undefined, - details: message || undefined, - }); + const explicitMessage = readValue(args, ["--text", "--message", "--summary"]); + const status = readValue(args, ["--status"]) ?? "running"; + const positionalParts: string[] = []; + while (true) { + const part = firstStandalonePositional(args); + if (part == null) break; + positionalParts.push(part); + } + const message = explicitMessage ?? positionalParts.join(" ").trim(); + const input: JsonObject = { status }; + if (message) { + input.summary = message; + input.nextAction = message; + input.details = message; + } + return collectGenericObjectArgs(args, input); } if (name === "report_result" || name === "report_validation") { return collectGenericObjectArgs(args, { @@ -10248,11 +10252,8 @@ function readRuntimeInfoVersion(value: unknown): string | null { } function shouldReplaceMachineRuntimeVersion(runtimeVersion: string | null): boolean { - return Boolean( - runtimeVersion && - runtimeVersion !== VERSION && - VERSION !== PLACEHOLDER_VERSION, - ); + if (VERSION === PLACEHOLDER_VERSION) return false; + return runtimeVersion == null || runtimeVersion !== VERSION; } async function initializeMachineRuntimeDaemon( diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index 7a6191e22..b8d075ed7 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -1966,7 +1966,12 @@ async function waitForMissionCloseoutAfterFinalize( const terminalMissionStatuses = new Set(["completed", "failed", "canceled", "intervention_required"]); const started = Date.now(); while (Date.now() - started < 10_000) { - const mission = await Promise.resolve(runtime.missionService.get(missionId)); + let mission: Awaited> | null = null; + try { + mission = await Promise.resolve(runtime.missionService.get(missionId)); + } catch { + return; + } const status = typeof mission?.status === "string" ? mission.status : ""; if (terminalMissionStatuses.has(status)) return; await new Promise((resolve) => setTimeout(resolve, 100)); @@ -1993,9 +1998,9 @@ function buildOrchestratorCoreDomainService(runtime: AdeRuntime): OpaqueService resumeRun: (args: Parameters[0]) => compactRunForTransport(service.resumeRun(args)), finalizeRun: async (args: Parameters[0]) => { - const result = runtime.aiOrchestratorService?.finalizeRun + const result = await (runtime.aiOrchestratorService?.finalizeRun ? runtime.aiOrchestratorService.finalizeRun(args as never) - : service.finalizeRun(args); + : service.finalizeRun(args)); await waitForMissionCloseoutAfterFinalize(runtime, args.runId, result); return result; }, @@ -2016,7 +2021,7 @@ function buildAiOrchestratorDomainService(runtime: AdeRuntime): OpaqueService | sendThreadMessage: async (args: Parameters[0]) => compactChatMessageForTransport(await service.sendThreadMessage(args)), finalizeRun: async (args: Parameters[0]) => { - const result = service.finalizeRun(args); + const result = await service.finalizeRun(args); await waitForMissionCloseoutAfterFinalize(runtime, args.runId, result); return result; }, diff --git a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts index ad18a9577..0bdfad3a4 100644 --- a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts +++ b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts @@ -2478,7 +2478,7 @@ describe("aiOrchestratorService", () => { summary: "Risk notes: no remaining product risks identified.", }); - await waitFor(() => fixture.missionService.get(mission.id)?.status === "completed"); + await waitFor(() => fixture.missionService.get(mission.id)?.status === "completed", 10_000); expect(fixture.missionService.get(mission.id)?.lastError).toBeNull(); const runView = await fixture.aiOrchestratorService.getRunView({ missionId: mission.id, runId }); expect(runView?.lifecycle.displayStatus).toBe("completed"); @@ -6695,7 +6695,7 @@ describe("aiOrchestratorService", () => { const finalizeResult = fixture.aiOrchestratorService.finalizeRun({ runId }); expect(finalizeResult.finalized).toBe(true); - await waitFor(() => fixture.missionService.get(mission.id)?.status === "completed"); + await waitFor(() => fixture.missionService.get(mission.id)?.status === "completed", 10_000); const refreshed = fixture.missionService.get(mission.id); expect(refreshed?.status).toBe("completed"); expect(refreshed?.steps.every((step) => step.status === "succeeded")).toBe(true); diff --git a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts index 79030957c..08a9f320f 100644 --- a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts +++ b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts @@ -2991,11 +2991,14 @@ Check all worker statuses and continue managing the mission from here. Read work typeof meta.stepType === "string" ? meta.stepType : "", typeof meta.taskType === "string" ? meta.taskType : "", ].join(" "); - const planningLike = - meta.readOnlyExecution === true - || (typeof meta.phaseKey === "string" && meta.phaseKey.trim().toLowerCase() === "planning") - || (typeof meta.stepType === "string" && meta.stepType.trim().toLowerCase() === "planning"); - const reviewRelated = !planningLike && /\breview\b/i.test(identityText); + const phaseIdentity = [ + typeof meta.phaseKey === "string" ? meta.phaseKey : "", + typeof meta.phaseName === "string" ? meta.phaseName : "", + typeof meta.stepType === "string" ? meta.stepType : "", + typeof meta.taskType === "string" ? meta.taskType : "", + ].join(" ").toLowerCase(); + const planningLike = /\bplanning\b/.test(phaseIdentity); + const reviewRelated = /\b(review|validation|audit|proof)\b/.test(`${identityText} ${phaseIdentity}`); const details: string[] = []; if (report) { collectReviewSummaryStrings(report.reviewSummary, details); @@ -3022,7 +3025,9 @@ Check all worker statuses and continue managing the mission from here. Read work const payload = isRecord(event.payload) ? event.payload : {}; const workerId = typeof payload.workerId === "string" ? payload.workerId : ""; const summary = typeof payload.summary === "string" ? payload.summary.trim() : ""; - const reviewRelated = /\breview\b/i.test(`${workerId} ${summary}`); + const reviewRelated = + event.eventType === "validation_report" + || /\b(review|validation|audit|proof)\b/i.test(`${workerId} ${summary}`); const planningRelated = /\bplanning\b/i.test(`${workerId} ${summary}`); const details: string[] = []; collectReviewSummaryStrings(payload.reviewSummary, details); @@ -3332,10 +3337,11 @@ Check all worker statuses and continue managing the mission from here. Read work : `Step ${step.stepKey} is in progress.`; // Best-effort: try to read durable step output file for enriched summary let stepOutputContent: string | null = null; - if (projectRoot) { + if (projectRoot && latestAttempt) { try { const sanitizedKey = step.stepKey.replace(/[^a-zA-Z0-9_-]/g, "_"); - const outputPath = nodePath.resolve(projectRoot, `.ade/step-output-${sanitizedKey}.md`); + const sanitizedAttemptId = latestAttempt.id.replace(/[^a-zA-Z0-9_-]/g, "_"); + const outputPath = nodePath.resolve(projectRoot, `.ade/step-output-${sanitizedKey}-attempt-${sanitizedAttemptId}.md`); if (fs.existsSync(outputPath)) { stepOutputContent = fs.readFileSync(outputPath, "utf-8").trim(); } @@ -5422,25 +5428,27 @@ Check all worker statuses and continue managing the mission from here. Read work } })(); - if (retrospective && projectRoot) { - const missionForState = missionService.get(missionId); - void updateMissionStateDocument({ - projectRoot, - missionId, - runId, - goal: missionForState?.prompt || missionForState?.title || "Mission run", - patch: { - reflections: orchestratorService.listReflections({ runId, limit: 200 }), - latestRetrospective: retrospective, - }, - }).catch((error) => { - logger.debug("ai_orchestrator.retrospective_mission_state_sync_failed", { - runId, - missionId, - error: error instanceof Error ? error.message : String(error), - }); - }); - } + const retrospectiveStateSync = retrospective && projectRoot + ? (async () => { + const missionForState = missionService.get(missionId); + await updateMissionStateDocument({ + projectRoot, + missionId, + runId, + goal: missionForState?.prompt || missionForState?.title || "Mission run", + patch: { + reflections: orchestratorService.listReflections({ runId, limit: 200 }), + latestRetrospective: retrospective, + }, + }); + })().catch((error) => { + logger.debug("ai_orchestrator.retrospective_mission_state_sync_failed", { + runId, + missionId, + error: error instanceof Error ? error.message : String(error), + }); + }) + : Promise.resolve(); logger.info("ai_orchestrator.run_finalized", { runId, @@ -5451,13 +5459,15 @@ Check all worker statuses and continue managing the mission from here. Read work }); if (finalStatus === "succeeded") { - void syncMissionFromRun(runId, "finalize_run_succeeded").catch((error) => { - logger.debug("ai_orchestrator.finalize_sync_failed", { - runId, - missionId, - error: error instanceof Error ? error.message : String(error), + void retrospectiveStateSync + .then(() => syncMissionFromRun(runId, "finalize_run_succeeded")) + .catch((error) => { + logger.debug("ai_orchestrator.finalize_sync_failed", { + runId, + missionId, + error: error instanceof Error ? error.message : String(error), + }); }); - }); } return finalized; diff --git a/apps/desktop/src/main/services/orchestrator/baseOrchestratorAdapter.ts b/apps/desktop/src/main/services/orchestrator/baseOrchestratorAdapter.ts index d8f1e2836..63e1d648f 100644 --- a/apps/desktop/src/main/services/orchestrator/baseOrchestratorAdapter.ts +++ b/apps/desktop/src/main/services/orchestrator/baseOrchestratorAdapter.ts @@ -689,11 +689,14 @@ export function buildFullPrompt( // Durable step output file if (!readOnlyExecution) { - const sanitizedStepKeyForOutput = step.stepKey.replace(/[^a-zA-Z0-9_-]/g, "_"); + const rawStepKeyForOutput = step.stepKey ?? step.id ?? step.title ?? "worker"; + const sanitizedStepKeyForOutput = String(rawStepKeyForOutput).replace(/[^a-zA-Z0-9_-]/g, "_"); + const rawAttemptIdForOutput = args.attempt?.id ?? step.lastAttemptId ?? "unknown"; + const sanitizedAttemptIdForOutput = String(rawAttemptIdForOutput).replace(/[^a-zA-Z0-9_-]/g, "_"); systemParts.push( [ `STEP OUTPUT FILE: When you complete your task, write a structured summary file at:`, - ` .ade/step-output-${sanitizedStepKeyForOutput}.md`, + ` .ade/step-output-${sanitizedStepKeyForOutput}-attempt-${sanitizedAttemptIdForOutput}.md`, "", "The file MUST contain these sections:", "## Summary", diff --git a/apps/desktop/src/main/services/orchestrator/coordinatorTools.test.ts b/apps/desktop/src/main/services/orchestrator/coordinatorTools.test.ts index 47c21b30a..87da533fd 100644 --- a/apps/desktop/src/main/services/orchestrator/coordinatorTools.test.ts +++ b/apps/desktop/src/main/services/orchestrator/coordinatorTools.test.ts @@ -6025,13 +6025,18 @@ describe("coordinatorTools file path containment", () => { const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-step-output-root-")); const maliciousKey = "../../sensitive"; const sanitized = maliciousKey.replace(/[^a-zA-Z0-9_-]/g, "_"); + const attemptId = "attempt-for-sensitive-output"; const outputDir = path.join(projectRoot, ".ade"); fs.mkdirSync(outputDir, { recursive: true }); - const scopedFile = path.join(outputDir, `step-output-${sanitized}.md`); + const scopedFile = path.join(outputDir, `step-output-${sanitized}-attempt-${attemptId}.md`); fs.writeFileSync(scopedFile, "scoped output", "utf-8"); const { tools } = createCoordinatorHarness({ - graph: { run: { metadata: {} }, steps: [], attempts: [] }, + graph: { + run: { metadata: {} }, + steps: [{ id: "step-sensitive", stepKey: maliciousKey }], + attempts: [{ id: attemptId, stepId: "step-sensitive", createdAt: "2026-01-01T00:00:00.000Z" }], + }, projectRoot, }); diff --git a/apps/desktop/src/main/services/orchestrator/coordinatorTools.ts b/apps/desktop/src/main/services/orchestrator/coordinatorTools.ts index 3486d9bb4..28fc49559 100644 --- a/apps/desktop/src/main/services/orchestrator/coordinatorTools.ts +++ b/apps/desktop/src/main/services/orchestrator/coordinatorTools.ts @@ -1675,7 +1675,7 @@ export function createCoordinatorToolSet(deps: { function phaseAllowsValidationWorker(phase: PhaseCard | null | undefined): boolean { if (!phase) return false; const stepType = resolvePhaseStepType(phase.phaseKey); - if (stepType === "validation" || stepType === "review" || stepType === "test_review") return true; + if (stepType.includes("validation") || stepType.includes("review")) return true; const gate = phase.validationGate; return gate.required === true || gate.tier === "dedicated"; } @@ -2271,10 +2271,10 @@ export function createCoordinatorToolSet(deps: { const name = args.name.trim().toLowerCase(); const roleName = args.roleName?.trim().toLowerCase() ?? ""; const prompt = args.prompt.trim().toLowerCase(); - const validationPattern = /\b(validat(?:e|ion|or))\b/; + const validationPattern = /\b(validat(?:e|ion|or)|review(?:er|ing)?|audit|proof)\b/; return ( (includeNameHeuristics && validationPattern.test(name)) - || (includeNameHeuristics && validationPattern.test(roleName)) + || validationPattern.test(roleName) || (includePromptHeuristics && prompt.includes("report_validation")) || (includePromptHeuristics && prompt.includes("verdict: \"pass\"")) || (includePromptHeuristics && prompt.includes("verdict \"pass\"")) @@ -2423,6 +2423,8 @@ export function createCoordinatorToolSet(deps: { const phaseHasSucceeded = (phase: PhaseCard): boolean => phaseHasSuccessfulCompletion(phase, (p) => getStepsForPhase(g, p)); + const phaseHasValidatedSucceeded = (phase: PhaseCard): boolean => + phaseHasValidatedSuccessfulCompletion(phase, (p) => getStepsForPhase(g, p)); const phaseHasNonTerminalStep = (phase: PhaseCard): boolean => getStepsForPhase(g, phase).some((step) => !TERMINAL_STEP_STATUSES.has(step.status)); @@ -2464,10 +2466,10 @@ export function createCoordinatorToolSet(deps: { const mustPrecedePredecessors = resolveMustPrecedePredecessors(sorted, currentPhase); for (const predecessorPhase of mustPrecedePredecessors) { - if (!phaseHasSucceeded(predecessorPhase)) { + if (!phaseHasValidatedSucceeded(predecessorPhase)) { return { valid: false, - reason: `Phase "${currentPhase.name}" cannot start until phase "${predecessorPhase.name}" succeeds (mustPrecede constraint).`, + reason: `Phase "${currentPhase.name}" cannot start until phase "${predecessorPhase.name}" succeeds with required validation complete (mustPrecede constraint).`, }; } } @@ -3294,7 +3296,7 @@ export function createCoordinatorToolSet(deps: { currentPhase, nextImplementationPhaseKey: resolveFirstPostPlanningPhaseKey(missionPhases), workerName, - roleName: null, + roleName: roleDef.name, prompt: normalizedObjective, validationContract: null, validationHeuristics: "prompt_only", @@ -6831,20 +6833,38 @@ Format: Lead with the concrete rule or fact, then brief context for WHY. One act const read_step_output = defineCoordinatorTool({ description: - "Read a worker's structured step output file (.ade/step-output-{stepKey}.md). Workers write these files as durable output records when they complete their tasks. Use this to understand what a worker accomplished, especially after context compaction.", + "Read a worker's structured step output file for the latest attempt. Workers write these files as durable output records when they complete their tasks. Use this to understand what a worker accomplished, especially after context compaction.", inputSchema: z.object({ stepKey: z.string().describe("The step key to read the output file for"), }), execute: async ({ stepKey }) => { try { const sanitized = stepKey.replace(/[^a-zA-Z0-9_-]/g, "_"); - const filePath = resolveWorkspacePath(`.ade/step-output-${sanitized}.md`, true); - if (!filePath) { - return { ok: false, error: "Path is outside mission workspace root" }; + const g = graph(); + const step = g.steps.find((entry) => entry.stepKey === stepKey || entry.id === stepKey) ?? null; + const latestAttempt = step + ? [...g.attempts] + .filter((attempt) => attempt.stepId === step.id) + .sort((a, b) => b.createdAt.localeCompare(a.createdAt))[0] ?? null + : null; + const candidateRelativePaths = latestAttempt + ? [`.ade/step-output-${sanitized}-attempt-${latestAttempt.id.replace(/[^a-zA-Z0-9_-]/g, "_")}.md`] + : [`.ade/step-output-${sanitized}.md`]; + let lastError: unknown = null; + for (const relativePath of candidateRelativePaths) { + const filePath = resolveWorkspacePath(relativePath, true); + if (!filePath) { + return { ok: false, error: "Path is outside mission workspace root" }; + } + try { + const content = fs.readFileSync(filePath, "utf-8"); + return { ok: true, stepKey, content }; + } catch (error) { + lastError = error; + } } try { - const content = fs.readFileSync(filePath, "utf-8"); - return { ok: true, stepKey, content }; + throw lastError; } catch (error) { return { ok: false, error: formatWorkspaceReadError("step output", stepKey, error) }; } @@ -7045,7 +7065,6 @@ Format: Lead with the concrete rule or fact, then brief context for WHY. One act }; } - const roleDef = role?.trim().length ? resolveRoleDefinition(teamRuntime, role.trim()) : null; const spawnPolicy = authorizeWorkerSpawnPolicy({ g, requestedModelId: modelId, @@ -7092,7 +7111,7 @@ Format: Lead with the concrete rule or fact, then brief context for WHY. One act currentPhase, nextImplementationPhaseKey: resolveFirstPostPlanningPhaseKey(missionPhases), workerName: name, - roleName: null, + roleName: normalizedRole.length > 0 ? normalizedRole : null, prompt, validationContract: null, validationHeuristics: "prompt_only", @@ -7359,12 +7378,15 @@ Format: Lead with the concrete rule or fact, then brief context for WHY. One act if (!taskPrompt.length) { return { ok: false, error: `tasks[${i}].prompt is required.` }; } + const normalizedRole = typeof rawTask.role === "string" && rawTask.role.trim().length > 0 + ? rawTask.role.trim() + : null; const phaseValidation = validateDelegationPromptForCurrentPhase({ currentPhaseKey, currentPhase, nextImplementationPhaseKey: resolveFirstPostPlanningPhaseKey(missionPhases), workerName: taskName, - roleName: null, + roleName: normalizedRole, prompt: taskPrompt, validationContract: null, validationHeuristics: "prompt_only", @@ -7372,9 +7394,6 @@ Format: Lead with the concrete rule or fact, then brief context for WHY. One act if (!phaseValidation.ok) { return { ok: false, error: phaseValidation.error }; } - const normalizedRole = typeof rawTask.role === "string" && rawTask.role.trim().length > 0 - ? rawTask.role.trim() - : null; const roleDef = normalizedRole ? resolveRoleDefinition(teamRuntime, normalizedRole) : null; if (normalizedRole && !roleDef) { return { ok: false, error: `Unknown role '${normalizedRole}' in active team template.` }; diff --git a/apps/desktop/src/main/services/orchestrator/executionPolicy.ts b/apps/desktop/src/main/services/orchestrator/executionPolicy.ts index 6d90661fe..501db0896 100644 --- a/apps/desktop/src/main/services/orchestrator/executionPolicy.ts +++ b/apps/desktop/src/main/services/orchestrator/executionPolicy.ts @@ -937,13 +937,44 @@ function validationSurrogateSearchText(step: OrchestratorStep): string { typeof meta.lastValidationReport === "object" && meta.lastValidationReport ? (meta.lastValidationReport as Record) : null; + const searchableValue = (value: unknown): string => { + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean") return String(value); + if (Array.isArray(value)) return value.map(searchableValue).filter(Boolean).join(" "); + if (value && typeof value === "object") { + return Object.values(value as Record).map(searchableValue).filter(Boolean).join(" "); + } + return ""; + }; const values = [ step.stepKey, step.title, + searchableValue(meta.targetPhaseKey), + searchableValue(meta.targetPhaseName), + searchableValue(meta.targetPhase), + searchableValue(meta.phase), + searchableValue(meta.phaseKey), + searchableValue(meta.phaseName), typeof meta.instructions === "string" ? meta.instructions : "", typeof meta.workerName === "string" ? meta.workerName : "", typeof lastResultReport?.summary === "string" ? lastResultReport.summary : "", + searchableValue(lastResultReport?.phase), + searchableValue(lastResultReport?.targetPhase), + searchableValue(lastResultReport?.targetPhaseKey), + searchableValue(lastResultReport?.reviewEvidence), + searchableValue(lastResultReport?.review_summary), + searchableValue(lastResultReport?.reviewSummary), + searchableValue(lastResultReport?.risk_notes), + searchableValue(lastResultReport?.riskNotes), typeof lastValidationReport?.summary === "string" ? lastValidationReport.summary : "", + searchableValue(lastValidationReport?.phase), + searchableValue(lastValidationReport?.targetPhase), + searchableValue(lastValidationReport?.targetPhaseKey), + searchableValue(lastValidationReport?.reviewEvidence), + searchableValue(lastValidationReport?.review_summary), + searchableValue(lastValidationReport?.reviewSummary), + searchableValue(lastValidationReport?.risk_notes), + searchableValue(lastValidationReport?.riskNotes), ]; return normalizeSearchableText(values.join(" ")); } @@ -1291,7 +1322,8 @@ export function buildExecutionPlanPreviewFromPhases(args: { phaseOrder.push({ phaseName, card }); } for (const phaseName of phaseMap.keys()) { - if (seenPhases.has(phaseName)) continue; + const normalizedPhaseName = normalizeSearchableText(phaseName); + if (normalizedPhaseName === "planning" || normalizedPhaseName === "analysis" || seenPhases.has(phaseName)) continue; seenPhases.add(phaseName); phaseOrder.push({ phaseName, card: null }); } diff --git a/apps/desktop/src/main/services/orchestrator/orchestratorService.test.ts b/apps/desktop/src/main/services/orchestrator/orchestratorService.test.ts index bfb129822..1825938d0 100644 --- a/apps/desktop/src/main/services/orchestrator/orchestratorService.test.ts +++ b/apps/desktop/src/main/services/orchestrator/orchestratorService.test.ts @@ -6409,7 +6409,7 @@ describe("orchestratorService", () => { fs.mkdirSync(path.join(fixture.projectRoot, ".ade"), { recursive: true }); fs.writeFileSync( - path.join(fixture.projectRoot, ".ade", "step-output-implementation-worker.md"), + path.join(fixture.projectRoot, ".ade", `step-output-implementation-worker-attempt-${attempt.id}.md`), [ "## Summary", "Implemented the preference banner and validated the helper.", diff --git a/apps/desktop/src/main/services/orchestrator/orchestratorService.ts b/apps/desktop/src/main/services/orchestrator/orchestratorService.ts index d5bf35a85..dee520479 100644 --- a/apps/desktop/src/main/services/orchestrator/orchestratorService.ts +++ b/apps/desktop/src/main/services/orchestrator/orchestratorService.ts @@ -328,9 +328,15 @@ function getWorkerCheckpointPath(worktreePath: string, stepKey: string): string return path.join(worktreePath, ".ade", "checkpoints", `${sanitizedStepKey}.md`); } -function getWorkerStepOutputPath(worktreePath: string, stepKey: string): string { - const sanitizedStepKey = stepKey.replace(/[^a-zA-Z0-9_-]/g, "_"); - return path.join(worktreePath, ".ade", `step-output-${sanitizedStepKey}.md`); +function sanitizeStepOutputPathPart(value: string | number | null | undefined): string { + const sanitized = String(value ?? "").replace(/[^a-zA-Z0-9_-]/g, "_"); + return sanitized.length > 0 ? sanitized : "unknown"; +} + +function getWorkerStepOutputPath(worktreePath: string, stepKey: string, attemptId: string | number): string { + const sanitizedStepKey = sanitizeStepOutputPathPart(stepKey); + const sanitizedAttemptId = sanitizeStepOutputPathPart(attemptId); + return path.join(worktreePath, ".ade", `step-output-${sanitizedStepKey}-attempt-${sanitizedAttemptId}.md`); } type NormalizedValidationContract = { @@ -1438,7 +1444,10 @@ function normalizeRecoveredWorkerResultReport(args: { ? planMarkdownRaw : ""; const planSummary = - typeof rawPlan?.summary === "string" && rawPlan.summary.trim().length > 0 && !looksLikeReportTemplatePlaceholder(rawPlan.summary) + typeof rawPlan?.summary === "string" + && rawPlan.summary.trim().length > 0 + && !looksLikeReportTemplatePlaceholder(rawPlan.summary) + && !looksLikePlanTemplatePlaceholder(rawPlan.summary) ? rawPlan.summary.trim() : ""; const normalizedPlan = rawPlan && planMarkdown.length > 0 @@ -1459,7 +1468,10 @@ function normalizeRecoveredWorkerResultReport(args: { : normalizedPlan ? "Planning step completed." : ""; - const summary = looksLikeReportTemplatePlaceholder(rawSummary) ? "" : rawSummary; + const summary = + looksLikeReportTemplatePlaceholder(rawSummary) || looksLikePlanTemplatePlaceholder(rawSummary) + ? "" + : rawSummary; if (!summary && !normalizedPlan) return null; const artifactsRaw = Array.isArray(args.payload.artifacts) ? args.payload.artifacts : []; const filesChangedRaw = Array.isArray(args.payload.filesChanged) @@ -7024,7 +7036,7 @@ export function createOrchestratorService({ ? laneRow.worktree_path.trim() : null; if (worktreePath) { - const stepOutputPath = getWorkerStepOutputPath(worktreePath, step.stepKey); + const stepOutputPath = getWorkerStepOutputPath(worktreePath, step.stepKey, attempt.id); recoveredPayload = extractReportResultPayloadFromStepOutput(stepOutputPath); if (recoveredPayload) recoveredFrom = "step_output"; } @@ -9247,7 +9259,7 @@ export function createOrchestratorService({ ? laneRow.worktree_path.trim() : null; if (worktreePath) { - recoveredPayload = extractReportResultPayloadFromStepOutput(getWorkerStepOutputPath(worktreePath, step.stepKey)); + recoveredPayload = extractReportResultPayloadFromStepOutput(getWorkerStepOutputPath(worktreePath, step.stepKey, attempt.id)); if (recoveredPayload) recoveredFrom = "step_output"; } } From d1bd94d25af78da31a00b96e92a84fa865f2a044 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sat, 16 May 2026 02:19:14 -0400 Subject: [PATCH 3/4] Extract shared mission phase reference helpers --- .../src/main/services/missions/phaseEngine.ts | 20 ++++++++++++++ .../services/orchestrator/coordinatorAgent.ts | 25 ++++-------------- .../services/orchestrator/coordinatorTools.ts | 26 ++++--------------- 3 files changed, 30 insertions(+), 41 deletions(-) diff --git a/apps/desktop/src/main/services/missions/phaseEngine.ts b/apps/desktop/src/main/services/missions/phaseEngine.ts index bed67a861..5d8b6c4c0 100644 --- a/apps/desktop/src/main/services/missions/phaseEngine.ts +++ b/apps/desktop/src/main/services/missions/phaseEngine.ts @@ -92,6 +92,26 @@ export function ensurePlanningPhase(phases: PhaseCard[], at: string = nowIso()): }); } +export function normalizePhaseRef(value: string | null | undefined): string { + return normalizePhaseKey(value); +} + +export function phaseMatchesRef(phase: PhaseCard, ref: string | null | undefined): boolean { + const normalized = normalizePhaseRef(ref); + if (!normalized) return false; + return normalizePhaseRef(phase.phaseKey) === normalized || normalizePhaseRef(phase.name) === normalized; +} + +export function findPhaseByRef(phases: PhaseCard[], ref: string | null | undefined): PhaseCard | null { + return phases.find((phase) => phaseMatchesRef(phase, ref)) ?? null; +} + +export function resolveMustPrecedePredecessors(phases: PhaseCard[], targetPhase: PhaseCard): PhaseCard[] { + return phases.filter((phase) => + (phase.orderingConstraints.mustPrecede ?? []).some((successorRef) => phaseMatchesRef(targetPhase, successorRef)) + ); +} + const DEFAULT_CLAUDE_PHASE_MODEL_ID = getDefaultModelDescriptor("claude")?.id ?? "anthropic/claude-sonnet-4-6"; const DEFAULT_CODEX_PHASE_MODEL_ID = getDefaultModelDescriptor("codex")?.id ?? "openai/gpt-5.5"; diff --git a/apps/desktop/src/main/services/orchestrator/coordinatorAgent.ts b/apps/desktop/src/main/services/orchestrator/coordinatorAgent.ts index ca91e6245..6a8777eac 100644 --- a/apps/desktop/src/main/services/orchestrator/coordinatorAgent.ts +++ b/apps/desktop/src/main/services/orchestrator/coordinatorAgent.ts @@ -72,6 +72,7 @@ import { normalizeCoordinatorToolName, updateDelegationContract, } from "./delegationContracts"; +import { findPhaseByRef, resolveMustPrecedePredecessors } from "../missions/phaseEngine"; // --------------------------------------------------------------------------- // Types @@ -2584,22 +2585,6 @@ export class CoordinatorAgent { .some((step) => !TERMINAL_STEP_STATUSES.has(step.status)); } - private phaseMatchesRef(phase: PhaseCard, ref: string): boolean { - const normalizedRef = ref.trim().toLowerCase(); - if (!normalizedRef) return false; - return phase.phaseKey.trim().toLowerCase() === normalizedRef || phase.name.trim().toLowerCase() === normalizedRef; - } - - private findPhaseByRef(phases: PhaseCard[], ref: string): PhaseCard | null { - return phases.find((phase) => this.phaseMatchesRef(phase, ref)) ?? null; - } - - private resolveMustPrecedePredecessors(phases: PhaseCard[], targetPhase: PhaseCard): PhaseCard[] { - return phases.filter((phase) => - (phase.orderingConstraints.mustPrecede ?? []).some((successorRef) => this.phaseMatchesRef(targetPhase, successorRef)) - ); - } - private resolvePhaseDependencyStepKeys( graph: ReturnType, phase: PhaseCard, @@ -2612,12 +2597,12 @@ export class CoordinatorAgent { const predecessorPhases = explicitMustFollow.length > 0 ? explicitMustFollow - .map((key) => this.findPhaseByRef(sorted, key)) + .map((key) => findPhaseByRef(sorted, key)) .filter((candidate): candidate is PhaseCard => Boolean(candidate)) : sorted.slice(0, Math.max(0, targetIndex)).filter((candidate) => candidate.orderingConstraints.mustBeFirst || candidate.validationGate.required === true ); - for (const predecessor of this.resolveMustPrecedePredecessors(sorted, phase)) { + for (const predecessor of resolveMustPrecedePredecessors(sorted, phase)) { if (!predecessorPhases.includes(predecessor)) predecessorPhases.push(predecessor); } const dependencyKeys: string[] = []; @@ -2653,10 +2638,10 @@ export class CoordinatorAgent { if (phase.orderingConstraints.mustBeLast && this.phaseHasOpenExecutionStepInGraph(graph, earlier)) return false; } for (const predecessorKey of explicitMustFollow) { - const predecessor = this.findPhaseByRef(sorted, predecessorKey); + const predecessor = findPhaseByRef(sorted, predecessorKey); if (predecessor && !this.phaseHasValidatedSuccess(graph, predecessor)) return false; } - for (const predecessor of this.resolveMustPrecedePredecessors(sorted, phase)) { + for (const predecessor of resolveMustPrecedePredecessors(sorted, phase)) { if (!this.phaseHasValidatedSuccess(graph, predecessor)) return false; } return true; diff --git a/apps/desktop/src/main/services/orchestrator/coordinatorTools.ts b/apps/desktop/src/main/services/orchestrator/coordinatorTools.ts index 28fc49559..5767f5483 100644 --- a/apps/desktop/src/main/services/orchestrator/coordinatorTools.ts +++ b/apps/desktop/src/main/services/orchestrator/coordinatorTools.ts @@ -61,7 +61,11 @@ import { hasConflictingDelegationContract, updateDelegationContract, } from "./delegationContracts"; -import { resolveFirstPostPlanningPhaseKey } from "../missions/phaseEngine"; +import { + findPhaseByRef, + resolveFirstPostPlanningPhaseKey, + resolveMustPrecedePredecessors, +} from "../missions/phaseEngine"; /** Timeout for autopilot agent startup (Promise.race guard). */ const AUTOPILOT_START_TIMEOUT_MS = 15_000; @@ -1652,26 +1656,6 @@ export function createCoordinatorToolSet(deps: { }; } - function normalizePhaseRef(value: string | null | undefined): string { - return typeof value === "string" ? value.trim().toLowerCase() : ""; - } - - function phaseMatchesRef(phase: PhaseCard, ref: string): boolean { - const normalized = normalizePhaseRef(ref); - if (!normalized) return false; - return normalizePhaseRef(phase.phaseKey) === normalized || normalizePhaseRef(phase.name) === normalized; - } - - function findPhaseByRef(phases: PhaseCard[], ref: string): PhaseCard | null { - return phases.find((phase) => phaseMatchesRef(phase, ref)) ?? null; - } - - function resolveMustPrecedePredecessors(phases: PhaseCard[], targetPhase: PhaseCard): PhaseCard[] { - return phases.filter((phase) => - (phase.orderingConstraints.mustPrecede ?? []).some((successorRef) => phaseMatchesRef(targetPhase, successorRef)) - ); - } - function phaseAllowsValidationWorker(phase: PhaseCard | null | undefined): boolean { if (!phase) return false; const stepType = resolvePhaseStepType(phase.phaseKey); From cf9b6b36d2434740804bd4383a20e5fc79930398 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sat, 16 May 2026 02:43:47 -0400 Subject: [PATCH 4/4] Preserve legacy worker step output recovery --- .../orchestrator/orchestratorService.test.ts | 90 +++++++++++++++++++ .../orchestrator/orchestratorService.ts | 29 +++++- 2 files changed, 116 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/main/services/orchestrator/orchestratorService.test.ts b/apps/desktop/src/main/services/orchestrator/orchestratorService.test.ts index 1825938d0..abf89beef 100644 --- a/apps/desktop/src/main/services/orchestrator/orchestratorService.test.ts +++ b/apps/desktop/src/main/services/orchestrator/orchestratorService.test.ts @@ -6459,6 +6459,96 @@ describe("orchestratorService", () => { } }); + it("recovers legacy durable step output files for in-flight workers", async () => { + const fixture = await createFixture(); + try { + const now = "2026-02-19T00:00:00.000Z"; + const transcriptDir = path.join(fixture.projectRoot, ".ade", "transcripts"); + fs.mkdirSync(transcriptDir, { recursive: true }); + const preSessionId = "session-1"; + const transcriptPath = path.join(transcriptDir, `${preSessionId}.log`); + fixture.db.run( + `insert or ignore into terminal_sessions( + id, lane_id, pty_id, tracked, title, started_at, ended_at, + exit_code, transcript_path, head_sha_start, head_sha_end, + status, last_output_preview, summary, tool_type, resume_command, last_output_at + ) values (?, ?, null, 1, 'Worker', ?, null, null, ?, null, null, + 'running', null, null, 'codex-orchestrated', null, ?)`, + [preSessionId, fixture.laneId, now, transcriptPath, now] + ); + + const started = fixture.service.startRun({ + missionId: fixture.missionId, + steps: [ + { + stepKey: "legacy-worker", + title: "Legacy Worker", + stepIndex: 0, + laneId: fixture.laneId, + executorKind: "opencode", + metadata: { + stepType: "implementation", + }, + } + ] + }); + const stepId = fixture.service.listSteps(started.run.id)[0]?.id; + if (!stepId) throw new Error("Expected implementation step"); + + const attempt = await fixture.service.startAttempt({ + runId: started.run.id, + stepId, + ownerId: "operator" + }); + if (!attempt.executorSessionId) throw new Error("Expected running session-backed attempt"); + + fs.mkdirSync(path.join(fixture.projectRoot, ".ade"), { recursive: true }); + fs.writeFileSync( + path.join(fixture.projectRoot, ".ade", "step-output-legacy-worker.md"), + [ + "## Summary", + "Recovered the legacy output file.", + "", + "## Files Changed", + "- `legacy.ts`", + "", + "## Tests", + "- `npm test` passed: 1 failed: 0 skipped: 0", + ].join("\n"), + "utf8" + ); + fs.writeFileSync( + transcriptPath, + [ + "codex", + "I’m sending the result through the CLI.", + "exec", + "/bin/zsh -lc 'ade coordinator report_result --payload @/tmp/result.json' in /tmp/mission-lane", + ].join("\n"), + "utf8" + ); + + const reconciled = await fixture.service.onTrackedSessionEnded({ + sessionId: attempt.executorSessionId, + laneId: fixture.laneId, + exitCode: 0 + }); + expect(reconciled).toBe(1); + + const after = fixture.service.listAttempts({ runId: started.run.id }).find((entry) => entry.id === attempt.id); + expect(after?.status).toBe("succeeded"); + expect(after?.errorClass).toBe("none"); + const [stepAfter] = fixture.service.listSteps(started.run.id); + const metadata = stepAfter?.metadata as Record; + const report = metadata.lastResultReport as Record; + expect(metadata.recoveredResultReportFromStepOutput).toBe(true); + expect(report.summary).toBe("Recovered the legacy output file."); + expect(report.filesChanged).toEqual(["legacy.ts"]); + } finally { + fixture.dispose(); + } + }); + it("does not recover report_result prompt templates as worker results", async () => { const fixture = await createFixture(); try { diff --git a/apps/desktop/src/main/services/orchestrator/orchestratorService.ts b/apps/desktop/src/main/services/orchestrator/orchestratorService.ts index dee520479..e76a565a4 100644 --- a/apps/desktop/src/main/services/orchestrator/orchestratorService.ts +++ b/apps/desktop/src/main/services/orchestrator/orchestratorService.ts @@ -339,6 +339,11 @@ function getWorkerStepOutputPath(worktreePath: string, stepKey: string, attemptI return path.join(worktreePath, ".ade", `step-output-${sanitizedStepKey}-attempt-${sanitizedAttemptId}.md`); } +function getLegacyWorkerStepOutputPath(worktreePath: string, stepKey: string): string { + const sanitizedStepKey = sanitizeStepOutputPathPart(stepKey); + return path.join(worktreePath, ".ade", `step-output-${sanitizedStepKey}.md`); +} + type NormalizedValidationContract = { level: "step" | "milestone" | "mission"; tier: "self" | "dedicated"; @@ -1423,6 +1428,17 @@ function extractReportResultPayloadFromStepOutput(filePath: string | null | unde } } +function extractReportResultPayloadFromWorkerStepOutput(args: { + worktreePath: string; + stepKey: string; + attemptId: string | number; +}): Record | null { + return ( + extractReportResultPayloadFromStepOutput(getWorkerStepOutputPath(args.worktreePath, args.stepKey, args.attemptId)) + ?? extractReportResultPayloadFromStepOutput(getLegacyWorkerStepOutputPath(args.worktreePath, args.stepKey)) + ); +} + function normalizeRecoveredWorkerResultReport(args: { payload: Record; step: OrchestratorStep; @@ -7036,8 +7052,11 @@ export function createOrchestratorService({ ? laneRow.worktree_path.trim() : null; if (worktreePath) { - const stepOutputPath = getWorkerStepOutputPath(worktreePath, step.stepKey, attempt.id); - recoveredPayload = extractReportResultPayloadFromStepOutput(stepOutputPath); + recoveredPayload = extractReportResultPayloadFromWorkerStepOutput({ + worktreePath, + stepKey: step.stepKey, + attemptId: attempt.id, + }); if (recoveredPayload) recoveredFrom = "step_output"; } } @@ -9259,7 +9278,11 @@ export function createOrchestratorService({ ? laneRow.worktree_path.trim() : null; if (worktreePath) { - recoveredPayload = extractReportResultPayloadFromStepOutput(getWorkerStepOutputPath(worktreePath, step.stepKey, attempt.id)); + recoveredPayload = extractReportResultPayloadFromWorkerStepOutput({ + worktreePath, + stepKey: step.stepKey, + attemptId: attempt.id, + }); if (recoveredPayload) recoveredFrom = "step_output"; } }